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

Merge branch 'master' into capdevon-RenderManager

Ryan McDonough 3 недель назад
Родитель
Сommit
e2957b2d12
37 измененных файлов с 801 добавлено и 430 удалено
  1. 1 0
      .github/.well-known/funding-manifest-urls
  2. 51 38
      .github/workflows/main.yml
  3. 1 1
      .github/workflows/screenshot-test-comment.yml
  4. 97 6
      jme3-android-native/openalsoft.gradle
  5. 1 0
      jme3-android-native/src/native/jme_bufferallocator/Android.mk
  6. 1 1
      jme3-android-native/src/native/jme_bufferallocator/Application.mk
  7. 1 1
      jme3-android-native/src/native/jme_decode/Android.mk
  8. 1 1
      jme3-android-native/src/native/jme_decode/Application.mk
  9. 1 0
      jme3-android-native/src/native/jme_decode/com_jme3_audio_plugins_NativeVorbisFile.c
  10. 40 94
      jme3-android-native/src/native/jme_openalsoft/Android.mk
  11. 1 1
      jme3-android-native/src/native/jme_openalsoft/Application.mk
  12. 4 3
      jme3-core/src/main/java/com/jme3/effect/influencers/NewtonianParticleInfluencer.java
  13. 140 57
      jme3-core/src/main/java/com/jme3/font/BitmapFont.java
  14. 179 98
      jme3-core/src/main/java/com/jme3/font/BitmapText.java
  15. 4 4
      jme3-core/src/main/java/com/jme3/material/RenderState.java
  16. 2 2
      jme3-core/src/main/java/com/jme3/renderer/RenderManager.java
  17. 22 16
      jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureDebugAppState.java
  18. 41 44
      jme3-core/src/main/java/com/jme3/shadow/PointLightShadowRenderer.java
  19. 46 45
      jme3-core/src/plugins/java/com/jme3/export/binary/BinaryExporter.java
  20. 1 0
      jme3-plugins/src/gltf/java/com/jme3/scene/plugins/gltf/LightsPunctualExtensionLoader.java
  21. 9 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/App.java
  22. 10 4
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ExtentReportExtension.java
  23. 117 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ExtentReportLogCapture.java
  24. 29 13
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestDriver.java
  25. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestIssue1773.testIssue1773_localSpace_f45.png
  26. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestIssue1773.testIssue1773_worldSpace_f45.png
  27. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_DefaultDirectionalLight_f12.png
  28. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_HighRoughness_f12.png
  29. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_LowRoughness_f12.png
  30. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_UpdatedDirectionalLight_f12.png
  31. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSimple.testPBRSimple_WithRealtimeBaking_f10.png
  32. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSimple.testPBRSimple_WithoutRealtimeBaking_f10.png
  33. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.model.shape.TestBillboard.testBillboard_fromAbove_f1.png
  34. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.model.shape.TestBillboard.testBillboard_fromFront_f1.png
  35. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.model.shape.TestBillboard.testBillboard_fromRight_f1.png
  36. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.post.TestFog.testFog_f1.png
  37. 1 1
      natives-snapshot.properties

+ 1 - 0
.github/.well-known/funding-manifest-urls

@@ -0,0 +1 @@
+https://jmonkeyengine.org/funding.json

+ 51 - 38
.github/workflows/main.yml

@@ -59,62 +59,75 @@ jobs:
   ScreenshotTests:
     name: Run Screenshot Tests
     runs-on: ubuntu-latest
+    container:
+      image: ghcr.io/onemillionworlds/opengl-docker-image:v1
     permissions:
       contents: read
     steps:
-    - uses: actions/checkout@v4
-    - name: Set up JDK 17
-      uses: actions/setup-java@v4
-      with:
-        java-version: '17'
-        distribution: 'temurin'
-    - name: Install Mesa3D
-      run: |
-        sudo apt-get update
-        sudo apt-get install -y mesa-utils libgl1-mesa-dri libgl1 libglx-mesa0 xvfb
-    - name: Set environment variables for Mesa3D
-      run: |
-        echo "LIBGL_ALWAYS_SOFTWARE=1" >> $GITHUB_ENV
-        echo "MESA_LOADER_DRIVER_OVERRIDE=llvmpipe" >> $GITHUB_ENV
-    - name: Start xvfb
-      run: |
-        sudo Xvfb :99 -ac -screen 0 1024x768x16 &
-        export DISPLAY=:99
-        echo "DISPLAY=:99" >> $GITHUB_ENV
-    - name: Verify Mesa3D Installation
-      run: |
-        glxinfo | grep "OpenGL"
-    - name: Validate the Gradle wrapper
-      uses: gradle/actions/wrapper-validation@v3
-    - name: Test with Gradle Wrapper
-      run: |
-        ./gradlew :jme3-screenshot-test:screenshotTest
-    - name: Upload Test Reports
-      uses: actions/upload-artifact@master
-      if: always()
-      with:
-        name: screenshot-test-report
-        retention-days: 30
-        path: |
-          **/build/reports/**
-          **/build/changed-images/**
-          **/build/test-results/**
+      - uses: actions/checkout@v4
+      - name: Start xvfb
+        run: |
+          Xvfb :99 -ac -screen 0 1024x768x16 &
+          export DISPLAY=:99
+          echo "DISPLAY=:99" >> $GITHUB_ENV
+      - name: Report GL/Vulkan
+        run: |
+          set -x
+          echo "DISPLAY=$DISPLAY"
+          glxinfo | grep -E "OpenGL version|OpenGL renderer|OpenGL vendor" || true
+          vulkaninfo --summary || true
+          echo "VK_ICD_FILENAMES=$VK_ICD_FILENAMES"
+          echo "MESA_LOADER_DRIVER_OVERRIDE=$MESA_LOADER_DRIVER_OVERRIDE"
+          echo "GALLIUM_DRIVER=$GALLIUM_DRIVER"
+      - name: Validate the Gradle wrapper
+        uses: gradle/actions/wrapper-validation@v3
+      - name: Test with Gradle Wrapper
+        run: |
+          ./gradlew :jme3-screenshot-test:screenshotTest
+      - name: Upload Test Reports
+        uses: actions/upload-artifact@master
+        if: always()
+        with:
+          name: screenshot-test-report
+          retention-days: 30
+          path: |
+            **/build/reports/**
+            **/build/changed-images/**
+            **/build/test-results/**
   # Build the natives on android
   BuildAndroidNatives:
     name: Build natives for android
     runs-on: ubuntu-latest
     container:
-      image: jmonkeyengine/buildenv-jme3:android
+      image: ghcr.io/cirruslabs/android-sdk:35-ndk
 
     steps:
       - name: Clone the repo
         uses: actions/checkout@v4
         with:
           fetch-depth: 1
+
+      - name: Setup Java 11
+        uses: actions/setup-java@v4
+        with:
+          distribution: temurin
+          java-version: '11'
+
+      - name: Check java version
+        run: java -version
+
+      - name: Install CMake
+        run: |
+          apt-get update
+          apt-get install -y cmake
+          cmake --version
+
       - name: Validate the Gradle wrapper
         uses: gradle/actions/wrapper-validation@v3
+
       - name: Build
         run: |
+          export ANDROID_NDK="$ANDROID_SDK_ROOT/ndk/$ANDROID_NDK_VERSION"
           ./gradlew -PuseCommitHashAsVersionName=true --no-daemon -PbuildNativeProjects=true \
           :jme3-android-native:assemble
 

+ 1 - 1
.github/workflows/screenshot-test-comment.yml

@@ -21,7 +21,7 @@ jobs:
       contents: read
     steps:
       - name: Wait for GitHub to register the workflow run
-        run: sleep 15
+        run: sleep 120
 
       - name: Wait for Screenshot Tests to complete
         uses: lewagon/[email protected]

+ 97 - 6
jme3-android-native/openalsoft.gradle

@@ -1,12 +1,12 @@
 // OpenAL Soft r1.21.1
 // TODO: update URL to jMonkeyEngine fork once it's updated with latest kcat's changes
-String openALSoftUrl = 'https://github.com/kcat/openal-soft/archive/1.21.1.zip'
+String openALSoftUrl = 'https://github.com/kcat/openal-soft/archive/1.24.3.zip'
 String openALSoftZipFile = 'OpenALSoft.zip'
 
 // OpenAL Soft directory the download is extracted into
 // Typically, the downloaded OpenAL Soft zip file will extract to a directory
 // called "openal-soft"
-String openALSoftFolder = 'openal-soft-1.21.1'
+String openALSoftFolder = 'openal-soft-1.24.3'
 
 //Working directories for the ndk build.
 String openalsoftBuildDir = "${buildDir}" + File.separator + 'openalsoft'
@@ -81,13 +81,103 @@ task copyJmeOpenALSoft(type: Copy, dependsOn: [copyOpenALSoft, copyJmeHeadersOpe
     from sourceDir
     into outputDir
 }
+// rootProject.ndkCommandPath must be set to your ndk-build wrapper or full ndk path
+def ndkPath = new File(rootProject.ndkCommandPath).getParent()
+def cmakeToolchain = "${ndkPath}/build/cmake/android.toolchain.cmake"
+
+// 1) list your ABIs here
+def openalAbis = [
+    "armeabi-v7a",
+    "arm64-v8a",
+    "x86",
+    "x86_64"
+]
+
+// 2) for each ABI, register a configure/build pair
+openalAbis.each { abi ->
+
+    // configure task
+    tasks.register("configureOpenAlSoft_${abi}", Exec) {
+        group = "external-native"
+        description = "Generate CMake build files for OpenAL-Soft [$abi]"
+
+        workingDir file("$openalsoftBuildDir/$openALSoftFolder")
+        commandLine = [
+            "cmake",
+            "-S", ".",
+            "-B", "cmake-build-${abi}",
+            "-G", "Unix Makefiles",                         // or Ninja
+            "-DCMAKE_TOOLCHAIN_FILE=${cmakeToolchain}",
+            "-DANDROID_PLATFORM=android-21",
+            "-DANDROID_ABI=${abi}",
+            "-DCMAKE_BUILD_TYPE=Release",
+            "-DALSOFT_UTILS=OFF",
+            "-DALSOFT_EXAMPLES=OFF",
+            "-DALSOFT_TESTS=OFF",
+            "-DALSOFT_BACKEND_OPENSL=ON",
+            '-DALSOFT_SHARED=OFF',
+            '-DBUILD_SHARED_LIBS=OFF',
+            '-DALSOFT_STATIC=ON',
+            '-DLIBTYPE=STATIC',
+            '-DCMAKE_CXX_FLAGS=-stdlib=libc++'
+        ]
+
+        dependsOn copyOpenALSoft
+    }
+
+    // build task
+    tasks.register("buildOpenAlSoft_${abi}", Exec) {
+        group = "external-native"
+        description = "Compile OpenAL-Soft into libopenalsoft.a for [$abi]"
+
+        dependsOn "configureOpenAlSoft_${abi}"
+        workingDir file("$openalsoftBuildDir/$openALSoftFolder")
+        commandLine = [
+            "cmake",
+            "--build", "cmake-build-${abi}",
+            "--config", "Release"
+        ]
+    }
+}
+
+// 3) optional: aggregate tasks
+tasks.register("configureOpenAlSoftAll") {
+    group = "external-native"
+    description = "Configure OpenAL-Soft for all ABIs"
+    dependsOn openalAbis.collect { "configureOpenAlSoft_${it}" }
+}
+
+tasks.register("buildOpenAlSoftAll") {
+    group = "external-native"
+    description = "Build OpenAL-Soft for all ABIs"
+    dependsOn openalAbis.collect { "buildOpenAlSoft_${it}" }
+}
 
-task buildOpenAlSoftNativeLib(type: Exec, dependsOn: copyJmeOpenALSoft) {
-//    println "openalsoft build dir: " + openalsoftBuildDir
-//    println "ndkCommandPath: " + project.ndkCommandPath
+task buildOpenAlSoftNativeLib(type: Exec) {
+    group = "external-native"
+    description = "Runs ndk-build on your JNI code, linking in the prebuilt OpenAL-Soft .a files"
+
+    dependsOn copyJmeOpenALSoft, buildOpenAlSoftAll
+
+    // where your Android.mk lives
     workingDir openalsoftBuildDir
+
+    // call the NDK build script
     executable rootProject.ndkCommandPath
-    args "-j" + Runtime.runtime.availableProcessors()
+
+    // pass in all ABIs (so ndk-build will rebuild your shared .so for each one),
+    // and pass in a custom var OPENALSOFT_BUILD_DIR so your Android.mk can find 
+    // the cmake-build-<ABI> folders.
+    args(
+        // let ndk-build know which ABIs to build for
+        "APP_ABI=armeabi-v7a,arm64-v8a,x86,x86_64",
+
+        // pass in the path to the CMake output root
+        "OPENALSOFT_BUILD_ROOT=${openalsoftBuildDir}/${openALSoftFolder}",
+
+        // parallel jobs
+        "-j${Runtime.runtime.availableProcessors()}"
+    )
 }
 
 task updatePreCompiledOpenAlSoftLibs(type: Copy, dependsOn: buildOpenAlSoftNativeLib) {
@@ -140,3 +230,4 @@ class MyDownload extends DefaultTask {
        ant.get(src: sourceUrl, dest: target)
     }
 }
+

+ 1 - 0
jme3-android-native/src/native/jme_bufferallocator/Android.mk

@@ -39,6 +39,7 @@ LOCAL_PATH := $(call my-dir)
 
 include $(CLEAR_VARS)
 
+LOCAL_CFLAGS := -DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=true
 LOCAL_LDLIBS     := -llog -Wl,-s
 
 LOCAL_MODULE := bufferallocatorjme

+ 1 - 1
jme3-android-native/src/native/jme_bufferallocator/Application.mk

@@ -36,4 +36,4 @@
 APP_PLATFORM := android-19
 # change this to 'debug' to see android logs
 APP_OPTIM := release
-APP_ABI := all
+APP_ABI := armeabi-v7a,arm64-v8a,x86,x86_64

+ 1 - 1
jme3-android-native/src/native/jme_decode/Android.mk

@@ -10,7 +10,7 @@ LOCAL_C_INCLUDES:= \
 		$(LOCAL_PATH) \
 		$(LOCAL_PATH)/Tremor
 
-LOCAL_CFLAGS := -std=gnu99 -DLIMIT_TO_64kHz -O0
+LOCAL_CFLAGS := -std=gnu99 -DLIMIT_TO_64kHz -O0 -DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=true
 LOCAL_LDLIBS := -lz -llog -Wl,-s
 	
 ifeq ($(TARGET_ARCH),arm)

+ 1 - 1
jme3-android-native/src/native/jme_decode/Application.mk

@@ -1,3 +1,3 @@
 APP_PLATFORM := android-9
 APP_OPTIM := release
-APP_ABI := all
+APP_ABI := armeabi-v7a,arm64-v8a,x86,x86_64

+ 1 - 0
jme3-android-native/src/native/jme_decode/com_jme3_audio_plugins_NativeVorbisFile.c

@@ -1,6 +1,7 @@
 #include <unistd.h>
 #include <stdlib.h>
 #include <errno.h>
+#include <string.h>
 
 #include "Tremor/ivorbisfile.h"
 

+ 40 - 94
jme3-android-native/src/native/jme_openalsoft/Android.mk

@@ -1,103 +1,49 @@
-TARGET_PLATFORM := android-19
+# jni/Android.mk
 
 LOCAL_PATH := $(call my-dir)
 
-include $(CLEAR_VARS)
-
-LOCAL_MODULE     := openalsoftjme
-
-LOCAL_C_INCLUDES += $(LOCAL_PATH) $(LOCAL_PATH)/include \
-		    $(LOCAL_PATH)/alc  $(LOCAL_PATH)/common
+# require the path to cmake-build-<ABI>
+ifndef OPENALSOFT_BUILD_ROOT
+$(error OPENALSOFT_BUILD_ROOT not set! pass it via ndk-build OPENALSOFT_BUILD_ROOT=/path/to/cmake-build-root)
+endif
 
-LOCAL_CPP_FEATURES += exceptions
+# assemble the path to this ABI's .a
+OPENAL_PREBUILT_DIR := $(OPENALSOFT_BUILD_ROOT)/cmake-build-$(TARGET_ARCH_ABI)
 
-LOCAL_CFLAGS     := -ffast-math -DAL_BUILD_LIBRARY -DAL_ALEXT_PROTOTYPES -fcommon -O0 -DRESTRICT=""
-LOCAL_LDLIBS     := -lOpenSLES -llog -Wl,-s
+# -----------------------------------------------------------------------------
+# 1) prebuilt static library
+include $(CLEAR_VARS)
+LOCAL_MODULE := openalsoft_prebuilt
+LOCAL_SRC_FILES := $(OPENAL_PREBUILT_DIR)/libopenal.a
+LOCAL_EXPORT_C_INCLUDES := $(OPENALSOFT_BUILD_ROOT)/include
+include $(PREBUILT_STATIC_LIBRARY)
 
-LOCAL_SRC_FILES  :=   al/auxeffectslot.cpp \
-                      al/buffer.cpp \
-                      al/effect.cpp \
-                      al/effects/autowah.cpp \
-                      al/effects/chorus.cpp \
-                      al/effects/compressor.cpp \
-                      al/effects/convolution.cpp \
-                      al/effects/dedicated.cpp \
-                      al/effects/distortion.cpp \
-                      al/effects/echo.cpp \
-                      al/effects/equalizer.cpp \
-                      al/effects/fshifter.cpp \
-                      al/effects/modulator.cpp \
-                      al/effects/null.cpp \
-                      al/effects/pshifter.cpp \
-                      al/effects/reverb.cpp \
-                      al/effects/vmorpher.cpp \
-                      al/error.cpp \
-                      al/event.cpp \
-                      al/extension.cpp \
-                      al/filter.cpp \
-                      al/listener.cpp \
-                      al/source.cpp \
-                      al/state.cpp \
-                      alc/alc.cpp \
-                      alc/alconfig.cpp \
-                      alc/alu.cpp \
-                      alc/backends/base.cpp \
-                      alc/backends/loopback.cpp \
-                      alc/backends/null.cpp \
-                      alc/backends/opensl.cpp \
-                      alc/backends/wave.cpp \
-                      alc/bformatdec.cpp \
-                      alc/buffer_storage.cpp \
-                      alc/converter.cpp \
-                      alc/effects/autowah.cpp \
-                      alc/effects/chorus.cpp \
-                      alc/effects/compressor.cpp \
-                      alc/effects/convolution.cpp \
-                      alc/effects/dedicated.cpp \
-                      alc/effects/distortion.cpp \
-                      alc/effects/echo.cpp \
-                      alc/effects/equalizer.cpp \
-                      alc/effects/fshifter.cpp \
-                      alc/effects/modulator.cpp \
-                      alc/effects/null.cpp \
-                      alc/effects/pshifter.cpp \
-                      alc/effects/reverb.cpp \
-                      alc/effects/vmorpher.cpp \
-                      alc/effectslot.cpp \
-                      alc/helpers.cpp \
-                      alc/hrtf.cpp \
-                      alc/panning.cpp \
-                      alc/uiddefs.cpp \
-                      alc/voice.cpp \
-                      common/alcomplex.cpp \
-                      common/alfstream.cpp \
-                      common/almalloc.cpp \
-                      common/alstring.cpp \
-                      common/dynload.cpp \
-                      common/polyphase_resampler.cpp \
-                      common/ringbuffer.cpp \
-                      common/strutils.cpp \
-                      common/threads.cpp \
-                      core/ambdec.cpp \
-                      core/bs2b.cpp \
-                      core/bsinc_tables.cpp \
-                      core/cpu_caps.cpp \
-                      core/devformat.cpp \
-                      core/except.cpp \
-                      core/filters/biquad.cpp \
-                      core/filters/nfc.cpp \
-                      core/filters/splitter.cpp \
-                      core/fmt_traits.cpp \
-                      core/fpu_ctrl.cpp \
-                      core/logging.cpp \
-                      core/mastering.cpp \
-                      core/mixer/mixer_c.cpp \
-                      core/uhjfilter.cpp \
-                      com_jme3_audio_android_AndroidAL.c \
-                      com_jme3_audio_android_AndroidALC.c \
-                      com_jme3_audio_android_AndroidEFX.c
+# -----------------------------------------------------------------------------
+# 2) your JNI wrapper
+include $(CLEAR_VARS)
+LOCAL_MODULE    := openalsoftjme
+LOCAL_SRC_FILES := \
+    com_jme3_audio_android_AndroidAL.c \
+    com_jme3_audio_android_AndroidALC.c \
+    com_jme3_audio_android_AndroidEFX.c
+
+LOCAL_C_INCLUDES  += \
+    $(LOCAL_PATH) \
+    $(LOCAL_PATH)/include \
+    $(LOCAL_PATH)/alc \
+    $(LOCAL_PATH)/common
+
+LOCAL_CPP_FEATURES          := exceptions rtti
+LOCAL_CFLAGS                := -ffast-math \
+                               -DAL_ALEXT_PROTOTYPES \
+                               -fcommon \
+                               -O0 \
+                               -DRESTRICT="" \
+                               -DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=true
+
+LOCAL_LDLIBS                := -lOpenSLES -llog -Wl,-s -lc++_static -lc++abi
+LOCAL_STATIC_LIBRARIES      := openalsoft_prebuilt
+# (or LOCAL_WHOLE_STATIC_LIBRARIES if you need every object pulled in)
 
 include $(BUILD_SHARED_LIBRARY)
 
-#                      Alc/mixer/hrtf_inc.c \
-

+ 1 - 1
jme3-android-native/src/native/jme_openalsoft/Application.mk

@@ -1,5 +1,5 @@
 APP_PLATFORM := android-19
 APP_OPTIM := release
-APP_ABI := all
+APP_ABI := armeabi-v7a,arm64-v8a,x86,x86_64
 APP_STL := c++_static
 

+ 4 - 3
jme3-core/src/main/java/com/jme3/effect/influencers/NewtonianParticleInfluencer.java

@@ -54,6 +54,8 @@ public class NewtonianParticleInfluencer extends DefaultParticleInfluencer {
     /** Emitters tangent rotation factor. */
     protected float surfaceTangentRotation;
 
+    protected Matrix3f tempMat3 = new Matrix3f();
+
     /**
      * Constructor. Sets velocity variation to 0.0f.
      */
@@ -71,9 +73,8 @@ public class NewtonianParticleInfluencer extends DefaultParticleInfluencer {
             // calculating surface tangent (velocity contains the 'normal' value)
             temp.set(particle.velocity.z * surfaceTangentFactor, particle.velocity.y * surfaceTangentFactor, -particle.velocity.x * surfaceTangentFactor);
             if (surfaceTangentRotation != 0.0f) {// rotating the tangent
-                Matrix3f m = new Matrix3f();
-                m.fromAngleNormalAxis(FastMath.PI * surfaceTangentRotation, particle.velocity);
-                temp = m.multLocal(temp);
+                tempMat3.fromAngleNormalAxis(FastMath.PI * surfaceTangentRotation, particle.velocity);
+                temp = tempMat3.multLocal(temp);
             }
             // applying normal factor (this must be done first)
             particle.velocity.multLocal(normalVelocity);

+ 140 - 57
jme3-core/src/main/java/com/jme3/font/BitmapFont.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,14 +31,22 @@
  */
 package com.jme3.font;
 
-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.material.Material;
 
 import java.io.IOException;
 
 /**
- * Represents a font within jME that is generated with the AngelCode Bitmap Font Generator
+ * Represents a font loaded from a bitmap font definition
+ * (e.g., generated by <a href="https://libgdx.com/wiki/tools/hiero">AngelCode Bitmap Font Generator</a>).
+ * It manages character sets, font pages (textures), and provides utilities for text measurement and rendering.
+ *
  * @author dhdd
+ * @author Yonghoon
  */
 public class BitmapFont implements Savable {
 
@@ -87,33 +95,30 @@ public class BitmapFont implements Savable {
         Bottom
     }
 
+    // The character set containing definitions for each character (glyph) in the font.
     private BitmapCharacterSet charSet;
+    // An array of materials, where each material corresponds to a font page (texture).
     private Material[] pages;
+    // Indicates whether this font is designed for right-to-left (RTL) text rendering.
     private boolean rightToLeft = false;
     // For cursive bitmap fonts in which letter shape is determined by the adjacent glyphs.
     private GlyphParser glyphParser;
 
     /**
-     * @return true, if this is a right-to-left font, otherwise it will return false.
+     * Creates a new instance of `BitmapFont`.
+     * This constructor is primarily used for deserialization.
      */
-    public boolean isRightToLeft() {
-        return rightToLeft;
+    public BitmapFont() {
     }
 
     /**
-     * Specify if this is a right-to-left font. By default it is set to false.
-     * This can be "overwritten" in the BitmapText constructor.
+     * Creates a new {@link BitmapText} instance initialized with this font.
+     * The label's size will be set to the font's rendered size, and its text content
+     * will be set to the provided string.
      *
-     * @param rightToLeft true &rarr; right-to-left, false &rarr; left-to-right
-     *     (default=false)
+     * @param content The initial text content for the label.
+     * @return A new {@link BitmapText} instance.
      */
-    public void setRightToLeft(boolean rightToLeft) {
-        this.rightToLeft = rightToLeft;
-    }
-
-    public BitmapFont() {
-    }
-
     public BitmapText createLabel(String content) {
         BitmapText label = new BitmapText(this);
         label.setSize(getCharSet().getRenderedSize());
@@ -121,27 +126,81 @@ public class BitmapFont implements Savable {
         return label;
     }
 
+    /**
+     * Checks if this font is configured for right-to-left (RTL) text rendering.
+     *
+     * @return true if this is a right-to-left font, otherwise false (default is left-to-right).
+     */
+    public boolean isRightToLeft() {
+        return rightToLeft;
+    }
+
+    /**
+     * Specifies whether this font should be rendered as right-to-left (RTL).
+     * By default, it is set to false (left-to-right).
+     *
+     * @param rightToLeft true to enable right-to-left rendering; false for left-to-right.
+     */
+    public void setRightToLeft(boolean rightToLeft) {
+        this.rightToLeft = rightToLeft;
+    }
+
+    /**
+     * Returns the preferred size of the font, which is typically its rendered size.
+     *
+     * @return The preferred size of the font in font units.
+     */
     public float getPreferredSize() {
         return getCharSet().getRenderedSize();
     }
 
+    /**
+     * Sets the character set for this font. The character set contains
+     * information about individual glyphs, their positions, and kerning data.
+     *
+     * @param charSet The {@link BitmapCharacterSet} to associate with this font.
+     */
     public void setCharSet(BitmapCharacterSet charSet) {
         this.charSet = charSet;
     }
 
+    /**
+     * Sets the array of materials (font pages) for this font. Each material
+     * corresponds to a texture page containing character bitmaps.
+     * The character set's page size is also updated based on the number of pages.
+     *
+     * @param pages An array of {@link Material} objects representing the font pages.
+     */
     public void setPages(Material[] pages) {
         this.pages = pages;
         charSet.setPageSize(pages.length);
     }
 
+    /**
+     * Retrieves a specific font page material by its index.
+     *
+     * @param index The index of the font page to retrieve.
+     * @return The {@link Material} for the specified font page.
+     * @throws IndexOutOfBoundsException if the index is out of bounds.
+     */
     public Material getPage(int index) {
         return pages[index];
     }
 
+    /**
+     * Returns the total number of font pages (materials) associated with this font.
+     *
+     * @return The number of font pages.
+     */
     public int getPageSize() {
         return pages.length;
     }
 
+    /**
+     * Retrieves the character set associated with this font.
+     *
+     * @return The {@link BitmapCharacterSet} of this font.
+     */
     public BitmapCharacterSet getCharSet() {
         return charSet;
     }
@@ -192,26 +251,19 @@ public class BitmapFont implements Savable {
         return c.getKerning(nextChar);
     }
 
-    @Override
-    public void write(JmeExporter ex) throws IOException {
-        OutputCapsule oc = ex.getCapsule(this);
-        oc.write(charSet, "charSet", null);
-        oc.write(pages, "pages", null);
-        oc.write(rightToLeft, "rightToLeft", false);
-        oc.write(glyphParser, "glyphParser", null);
-    }
-
-    @Override
-    public void read(JmeImporter im) throws IOException {
-        InputCapsule ic = im.getCapsule(this);
-        charSet = (BitmapCharacterSet) ic.readSavable("charSet", null);
-        Savable[] pagesSavable = ic.readSavableArray("pages", null);
-        pages = new Material[pagesSavable.length];
-        System.arraycopy(pagesSavable, 0, pages, 0, pages.length);
-        rightToLeft = ic.readBoolean("rightToLeft", false);
-        glyphParser = (GlyphParser) ic.readSavable("glyphParser", null);
-    }
-
+    /**
+     * Calculates the width of the given text in font units.
+     * This method accounts for character advances, kerning, and line breaks.
+     * It also attempts to skip custom color tags (e.g., "\#RRGGBB#" or "\#RRGGBBAA#")
+     * based on a specific format.
+     * <p>
+     * Note: This method calculates width in "font units" where the font's
+     * {@link BitmapCharacterSet#getRenderedSize() rendered size} is the base.
+     * Actual pixel scaling for display is typically handled by {@link BitmapText}.
+     *
+     * @param text The text to measure.
+     * @return The maximum line width of the text in font units.
+     */
     public float getLineWidth(CharSequence text) {
         // This method will probably always be a bit of a maintenance
         // nightmare since it bases its calculation on a different
@@ -252,29 +304,36 @@ public class BitmapFont implements Savable {
         boolean firstCharOfLine = true;
 //        float sizeScale = (float) block.getSize() / charSet.getRenderedSize();
         float sizeScale = 1f;
-        CharSequence characters = glyphParser != null ? glyphParser.parse(text) : text;
 
-        for (int i = 0; i < characters.length(); i++) {
-            char theChar = characters.charAt(i);
-            if (theChar == '\n') {
+        // Use GlyphParser if available for complex script shaping (e.g., cursive fonts).
+        CharSequence processedText = glyphParser != null ? glyphParser.parse(text) : text;
+
+        for (int i = 0; i < processedText.length(); i++) {
+            char currChar = processedText.charAt(i);
+            if (currChar == '\n') {
                 maxLineWidth = Math.max(maxLineWidth, lineWidth);
                 lineWidth = 0f;
                 firstCharOfLine = true;
                 continue;
             }
-            BitmapCharacter c = charSet.getCharacter(theChar);
+            BitmapCharacter c = charSet.getCharacter(currChar);
             if (c != null) {
-                if (theChar == '\\' && i < characters.length() - 1 && characters.charAt(i + 1) == '#') {
-                    if (i + 5 < characters.length() && characters.charAt(i + 5) == '#') {
+                // Custom color tag skipping logic:
+                // Assumes tags are of the form `\#RRGGBB#` (9 chars total) or `\#RRGGBBAA#` (12 chars total).
+                if (currChar == '\\' && i < processedText.length() - 1 && processedText.charAt(i + 1) == '#') {
+                    // Check for `\#XXXXX#` (6 chars after '\', including final '#')
+                    if (i + 5 < processedText.length() && processedText.charAt(i + 5) == '#') {
                         i += 5;
                         continue;
-                    } else if (i + 8 < characters.length() && characters.charAt(i + 8) == '#') {
+                    }
+                    // Check for `\#XXXXXXXX#` (9 chars after '\', including final '#')
+                    else if (i + 8 < processedText.length() && processedText.charAt(i + 8) == '#') {
                         i += 8;
                         continue;
                     }
                 }
                 if (!firstCharOfLine) {
-                    lineWidth += findKerningAmount(lastChar, theChar) * sizeScale;
+                    lineWidth += findKerningAmount(lastChar, currChar) * sizeScale;
                 } else {
                     if (rightToLeft) {
                         // Ignore offset, so it will be compatible with BitmapText.getLineWidth().
@@ -292,7 +351,7 @@ public class BitmapFont implements Savable {
                 // If this is the last character of a line, then we really should
                 // have only added its width. The advance may include extra spacing
                 // that we don't care about.
-                if (i == characters.length() - 1 || characters.charAt(i + 1) == '\n') {
+                if (i == processedText.length() - 1 || processedText.charAt(i + 1) == '\n') {
                     if (rightToLeft) {
                         // In RTL text we move the letter x0 by its xAdvance, so
                         // we should add it to lineWidth.
@@ -315,30 +374,54 @@ public class BitmapFont implements Savable {
         return Math.max(maxLineWidth, lineWidth);
     }
 
-
     /**
-     * Merge two fonts.
-     * If two font have the same style, merge will fail.
-     * @param newFont Style must be assigned to this.
-     * author: Yonghoon
+     * Merges another {@link BitmapFont} into this one.
+     * This operation combines the character sets and font pages.
+     * If both fonts contain the same style, the merge will fail and throw a RuntimeException.
+     *
+     * @param newFont The {@link BitmapFont} to merge into this one. It must have a style assigned.
      */
     public void merge(BitmapFont newFont) {
         charSet.merge(newFont.charSet);
         final int size1 = this.pages.length;
         final int size2 = newFont.pages.length;
 
-        Material[] tmp = new Material[size1+size2];
+        Material[] tmp = new Material[size1 + size2];
         System.arraycopy(this.pages, 0, tmp, 0, size1);
         System.arraycopy(newFont.pages, 0, tmp, size1, size2);
 
         this.pages = tmp;
-
-//        this.pages = Arrays.copyOf(this.pages, size1+size2);
-//        System.arraycopy(newFont.pages, 0, this.pages, size1, size2);
     }
 
+    /**
+     * Sets the style for the font's character set.
+     * This method is typically used when a font file contains only one style
+     * but needs to be assigned a specific style identifier for merging
+     * with other multi-style fonts.
+     *
+     * @param style The integer style identifier to set.
+     */
     public void setStyle(int style) {
         charSet.setStyle(style);
     }
 
-}
+    @Override
+    public void write(JmeExporter ex) throws IOException {
+        OutputCapsule oc = ex.getCapsule(this);
+        oc.write(charSet, "charSet", null);
+        oc.write(pages, "pages", null);
+        oc.write(rightToLeft, "rightToLeft", false);
+        oc.write(glyphParser, "glyphParser", null);
+    }
+
+    @Override
+    public void read(JmeImporter im) throws IOException {
+        InputCapsule ic = im.getCapsule(this);
+        charSet = (BitmapCharacterSet) ic.readSavable("charSet", null);
+        Savable[] pagesSavable = ic.readSavableArray("pages", null);
+        pages = new Material[pagesSavable.length];
+        System.arraycopy(pagesSavable, 0, pages, 0, pages.length);
+        rightToLeft = ic.readBoolean("rightToLeft", false);
+        glyphParser = (GlyphParser) ic.readSavable("glyphParser", null);
+    }
+}

+ 179 - 98
jme3-core/src/main/java/com/jme3/font/BitmapText.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,20 +38,38 @@ import com.jme3.math.ColorRGBA;
 import com.jme3.renderer.RenderManager;
 import com.jme3.scene.Node;
 import com.jme3.util.clone.Cloner;
+
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 /**
+ * `BitmapText` is a spatial node that displays text using a {@link BitmapFont}.
+ * It handles text layout, alignment, wrapping, coloring, and styling based on
+ * the properties set via its methods. The text is rendered as a series of
+ * quads (rectangles) with character textures from the font's pages.
+ *
  * @author YongHoon
  */
 public class BitmapText extends Node {
 
+    // The font used to render this text.
     private BitmapFont font;
+    // Stores the text content and its layout properties (size, box, alignment, etc.).
     private StringBlock block;
+    // A flag indicating whether the text needs to be re-assembled
     private boolean needRefresh = true;
+    // An array of `BitmapTextPage` instances, each corresponding to a font page.
     private BitmapTextPage[] textPages;
+    // Manages the individual letter quads, their positions, colors, and styles.
     private Letters letters;
 
+    /**
+     * Creates a new `BitmapText` instance using the specified font.
+     * The text will be rendered left-to-right by default, unless the font itself
+     * is configured for right-to-left rendering.
+     *
+     * @param font The {@link BitmapFont} to use for rendering the text (not null).
+     */
     public BitmapText(BitmapFont font) {
         this(font, font.isRightToLeft(), false);
     }
@@ -69,6 +87,15 @@ public class BitmapText extends Node {
         this(font, rightToLeft, false);
     }
 
+    /**
+     * Creates a new `BitmapText` instance with the specified font, text direction,
+     * and a flag for array-based rendering.
+     *
+     * @param font The {@link BitmapFont} to use for rendering the text (not null).
+     * @param rightToLeft true for right-to-left text rendering, false for left-to-right.
+     * @param arrayBased If true, the internal text pages will use array-based buffers for rendering.
+     * This might affect performance or compatibility depending on the renderer.
+     */
     public BitmapText(BitmapFont font, boolean rightToLeft, boolean arrayBased) {
         textPages = new BitmapTextPage[font.getPageSize()];
         for (int page = 0; page < textPages.length; page++) {
@@ -84,7 +111,7 @@ public class BitmapText extends Node {
 
     @Override
     public BitmapText clone() {
-        return (BitmapText)super.clone(false);
+        return (BitmapText) super.clone(false);
     }
 
     /**
@@ -114,13 +141,19 @@ public class BitmapText extends Node {
         // so I guess cloning doesn't come up that often.
     }
 
+    /**
+     * Returns the {@link BitmapFont} currently used by this `BitmapText` instance.
+     *
+     * @return The {@link BitmapFont} object.
+     */
     public BitmapFont getFont() {
         return font;
     }
 
     /**
-     * Changes text size
-     * @param size text size
+     * Sets the size of the text. This value scales the font's base character sizes.
+     *
+     * @param size The desired text size (e.g., in world units or pixels).
      */
     public void setSize(float size) {
         block.setSize(size);
@@ -128,13 +161,20 @@ public class BitmapText extends Node {
         letters.invalidate();
     }
 
+    /**
+     * Returns the current size of the text.
+     *
+     * @return The text size.
+     */
     public float getSize() {
         return block.getSize();
     }
 
     /**
+     * Sets the text content to be displayed.
      *
-     * @param text charsequence to change text to
+     * @param text The `CharSequence` (e.g., `String` or `StringBuilder`) to display.
+     * If null, the text will be set to an empty string.
      */
     public void setText(CharSequence text) {
         // note: text.toString() is free if text is already a java.lang.String.
@@ -142,72 +182,50 @@ public class BitmapText extends Node {
     }
 
     /**
+     * Sets the text content to be displayed.
+     * If the new text is the same as the current text, no update occurs.
+     * Otherwise, the internal `StringBlock` and `Letters` objects are updated,
+     * and a refresh is flagged to re-layout the text.
      *
-     * @param text String to change text to
+     * @param text The `String` to display. If null, the text will be set to an empty string.
      */
     public void setText(String text) {
         text = text == null ? "" : text;
-        if (text == block.getText() || block.getText().equals(text)) {
+        if (block.getText().equals(text)) {
             return;
         }
 
-        /*
-        The problem with the below block is that StringBlock carries
-        pretty much all of the text-related state of the BitmapText such
-        as size, text box, alignment, etc.
-
-        I'm not sure why this change was needed and the commit message was
-        not entirely helpful because it purports to fix a problem that I've
-        never encountered.
-
-        If block.setText("") doesn't do the right thing then that's where
-        the fix should go because StringBlock carries too much information to
-        be blown away every time.  -pspeed
-
-        Change was made:
-        http://code.google.com/p/jmonkeyengine/source/detail?spec=svn9389&r=9389
-        Diff:
-        http://code.google.com/p/jmonkeyengine/source/diff?path=/trunk/engine/src/core/com/jme3/font/BitmapText.java&format=side&r=9389&old_path=/trunk/engine/src/core/com/jme3/font/BitmapText.java&old=8843
-
-        // If the text is empty, reset
-        if (text.isEmpty()) {
-            detachAllChildren();
-
-            for (int page = 0; page < textPages.length; page++) {
-                textPages[page] = new BitmapTextPage(font, true, page);
-                attachChild(textPages[page]);
-            }
-
-            block = new StringBlock();
-            letters = new Letters(font, block, letters.getQuad().isRightToLeft());
-        }
-        */
-
         // Update the text content
         block.setText(text);
         letters.setText(text);
-
-        // Flag for refresh
         needRefresh = true;
     }
 
     /**
-     * @return returns text
+     * Returns the current text content displayed by this `BitmapText` instance.
+     *
+     * @return The text content as a `String`.
      */
     public String getText() {
         return block.getText();
     }
 
     /**
-     * @return color of the text
+     * Returns the base color applied to the entire text.
+     * Note: Substring colors set via `setColor(int, int, ColorRGBA)` or
+     * `setColor(String, ColorRGBA)` will override this base color for their respective ranges.
+     *
+     * @return The base {@link ColorRGBA} of the text.
      */
     public ColorRGBA getColor() {
         return letters.getBaseColor();
     }
 
     /**
-     * changes text color. all substring colors are deleted.
-     * @param color new color of text
+     * Sets the base color for the entire text.
+     * This operation will clear any previously set substring colors.
+     *
+     * @param color The new base {@link ColorRGBA} for the text.
      */
     public void setColor(ColorRGBA color) {
         letters.setColor(color);
@@ -216,26 +234,34 @@ public class BitmapText extends Node {
     }
 
     /**
-     *  Sets an overall alpha that will be applied to all
-     *  letters.  If the alpha passed is -1 then alpha reverts
-     *  to default... which will be 1 for anything unspecified
-     *  and color tags will be reset to 1 or their encoded
-     *  alpha.
+     * Sets an overall alpha (transparency) value that will be applied to all
+     * letters in the text.
+     * If the alpha passed is -1, the alpha reverts to its default behavior:
+     * 1.0 for unspecified parts, and the encoded alpha from any color tags.
      *
-     * @param alpha the desired alpha, or -1 to revert to the default
+     * @param alpha The desired alpha value (0.0 for fully transparent, 1.0 for fully opaque),
+     * or -1 to revert to default alpha behavior.
      */
     public void setAlpha(float alpha) {
         letters.setBaseAlpha(alpha);
         needRefresh = true;
     }
 
+    /**
+     * Returns the current base alpha value applied to the text.
+     *
+     * @return The base alpha value, or -1 if default alpha behavior is active.
+     */
     public float getAlpha() {
         return letters.getBaseAlpha();
     }
 
     /**
-     * Define the area where the BitmapText will be rendered.
-     * @param rect position and size box where text is rendered
+     * Defines a rectangular bounding box within which the text will be rendered.
+     * This box is used for text wrapping and alignment.
+     *
+     * @param rect The {@link Rectangle} defining the position (x, y) and size (width, height)
+     * of the text rendering area.
      */
     public void setBox(Rectangle rect) {
         block.setTextBox(rect);
@@ -244,14 +270,19 @@ public class BitmapText extends Node {
     }
 
     /**
-     * @return height of the line
+     * Returns the height of a single line of text, scaled by the current text size.
+     *
+     * @return The calculated line height.
      */
     public float getLineHeight() {
         return font.getLineHeight(block);
     }
 
     /**
-     * @return height of whole text block
+     * Calculates and returns the total height of the entire text block,
+     * considering all lines and the defined text box (if any).
+     *
+     * @return The total height of the text block.
      */
     public float getHeight() {
         if (needRefresh) {
@@ -266,7 +297,9 @@ public class BitmapText extends Node {
     }
 
     /**
-     * @return width of line
+     * Calculates and returns the maximum width of any line in the text block.
+     *
+     * @return The maximum line width of the text.
      */
     public float getLineWidth() {
         if (needRefresh) {
@@ -282,7 +315,9 @@ public class BitmapText extends Node {
     }
 
     /**
-     * @return line count
+     * Returns the number of lines the text currently occupies.
+     *
+     * @return The total number of lines.
      */
     public int getLineCount() {
         if (needRefresh) {
@@ -291,14 +326,21 @@ public class BitmapText extends Node {
         return block.getLineCount();
     }
 
+    /**
+     * Returns the current line wrapping mode set for this text.
+     *
+     * @return The {@link LineWrapMode} enum value.
+     */
     public LineWrapMode getLineWrapMode() {
         return block.getLineWrapMode();
     }
 
     /**
-     * Set horizontal alignment. Applicable only when text bound is set.
+     * Sets the horizontal alignment for the text within its bounding box.
+     * This is only applicable if a text bounding box has been set using {@link #setBox(Rectangle)}.
      *
-     * @param align the desired alignment (such as Align.Left)
+     * @param align The desired horizontal alignment (e.g., {@link Align#Left}, {@link Align#Center}, {@link Align#Right}).
+     * @throws RuntimeException If a bounding box is not set and `align` is not `Align.Left`.
      */
     public void setAlignment(BitmapFont.Align align) {
         if (block.getTextBox() == null && align != Align.Left) {
@@ -310,9 +352,11 @@ public class BitmapText extends Node {
     }
 
     /**
-     * Set vertical alignment. Applicable only when text bound is set.
+     * Sets the vertical alignment for the text within its bounding box.
+     * This is only applicable if a text bounding box has been set using {@link #setBox(Rectangle)}.
      *
-     * @param align the desired alignment (such as Align.Top)
+     * @param align The desired vertical alignment (e.g., {@link VAlign#Top}, {@link VAlign#Center}, {@link VAlign#Bottom}).
+     * @throws RuntimeException If a bounding box is not set and `align` is not `VAlign.Top`.
      */
     public void setVerticalAlignment(BitmapFont.VAlign align) {
         if (block.getTextBox() == null && align != VAlign.Top) {
@@ -323,28 +367,42 @@ public class BitmapText extends Node {
         needRefresh = true;
     }
 
+    /**
+     * Returns the current horizontal alignment set for the text.
+     *
+     * @return The current {@link Align} value.
+     */
     public BitmapFont.Align getAlignment() {
         return block.getAlignment();
     }
 
+    /**
+     * Returns the current vertical alignment set for the text.
+     *
+     * @return The current {@link VAlign} value.
+     */
     public BitmapFont.VAlign getVerticalAlignment() {
         return block.getVerticalAlignment();
     }
 
     /**
-     * Set the font style of substring. If font doesn't contain style, default style is used
-     * @param start start index to set style. inclusive.
-     * @param end   end index to set style. EXCLUSIVE.
-     * @param style the style to apply
+     * Sets the font style for a specific substring of the text.
+     * If the font does not contain the specified style, the default style will be used.
+     *
+     * @param start The starting index of the substring (inclusive).
+     * @param end   The ending index of the substring (exclusive).
+     * @param style The integer style identifier to apply.
      */
     public void setStyle(int start, int end, int style) {
         letters.setStyle(start, end, style);
     }
 
     /**
-     * Set the font style of substring. If font doesn't contain style, default style is applied
-     * @param regexp regular expression
-     * @param style  the style to apply
+     * Sets the font style for all substrings matching a given regular expression.
+     * If the font does not contain the specified style, the default style will be used.
+     *
+     * @param regexp The regular expression string to match against the text.
+     * @param style  The integer style identifier to apply.
      */
     public void setStyle(String regexp, int style) {
         Pattern p = Pattern.compile(regexp);
@@ -355,10 +413,11 @@ public class BitmapText extends Node {
     }
 
     /**
-     * Set the color of substring.
-     * @param start start index to set style. inclusive.
-     * @param end   end index to set style. EXCLUSIVE.
-     * @param color the desired color
+     * Sets the color for a specific substring of the text.
+     *
+     * @param start The starting index of the substring (inclusive).
+     * @param end   The ending index of the substring (exclusive).
+     * @param color The desired {@link ColorRGBA} to apply to the substring.
      */
     public void setColor(int start, int end, ColorRGBA color) {
         letters.setColor(start, end, color);
@@ -367,9 +426,10 @@ public class BitmapText extends Node {
     }
 
     /**
-     * Set the color of substring.
-     * @param regexp regular expression
-     * @param color  the desired color
+     * Sets the color for all substrings matching a given regular expression.
+     *
+     * @param regexp The regular expression string to match against the text.
+     * @param color  The desired {@link ColorRGBA} to apply.
      */
     public void setColor(String regexp, ColorRGBA color) {
         Pattern p = Pattern.compile(regexp);
@@ -382,7 +442,10 @@ public class BitmapText extends Node {
     }
 
     /**
-     * @param tabs tab positions
+     * Sets custom tab stop positions for the text.
+     * Tab characters (`\t`) will align to these specified positions.
+     *
+     * @param tabs An array of float values representing the horizontal tab stop positions.
      */
     public void setTabPosition(float... tabs) {
         block.setTabPosition(tabs);
@@ -391,8 +454,10 @@ public class BitmapText extends Node {
     }
 
     /**
-     * used for the tabs over the last tab position.
-     * @param width tab size
+     * Sets the default width for tabs that extend beyond the last defined tab position.
+     * This value is used if a tab character is encountered after all custom tab stops have been passed.
+     *
+     * @param width The default width for tabs in font units.
      */
     public void setTabWidth(float width) {
         block.setTabWidth(width);
@@ -401,10 +466,10 @@ public class BitmapText extends Node {
     }
 
     /**
-     * for setLineWrapType(LineWrapType.NoWrap),
-     * set the last character when the text exceeds the bound.
+     * When {@link LineWrapMode#NoWrap} is used and the text exceeds the bounding box,
+     * this character will be appended to indicate truncation (e.g., '...').
      *
-     * @param c the character to indicate truncated text
+     * @param c The character to use as the ellipsis.
      */
     public void setEllipsisChar(char c) {
         block.setEllipsisChar(c);
@@ -413,12 +478,18 @@ public class BitmapText extends Node {
     }
 
     /**
-     * Available only when bounding is set. <code>setBox()</code> method call is needed in advance.
-     * true when
-     * @param wrap NoWrap   : Letters over the text bound is not shown. the last character is set to '...'(0x2026)
-     *             Character: Character is split at the end of the line.
-     *             Word     : Word is split at the end of the line.
-     *             Clip     : The text is hard-clipped at the border including showing only a partial letter if it goes beyond the text bound.
+     * Sets the line wrapping mode for the text. This is only applicable when
+     * a text bounding box has been set using {@link #setBox(Rectangle)}.
+     *
+     * @param wrap The desired {@link LineWrapMode}:
+     * <ul>
+     * <li>{@link LineWrapMode#NoWrap}: Letters exceeding the text bound are not shown.
+     * The last visible character might be replaced by an ellipsis character
+     * (set via {@link #setEllipsisChar(char)}).</li>
+     * <li>{@link LineWrapMode#Character}: Text is split at the end of the line, even in the middle of a word.</li>
+     * <li>{@link LineWrapMode#Word}: Words are split at the end of the line.</li>
+     * <li>{@link LineWrapMode#Clip}: The text is hard-clipped at the border, potentially showing only a partial letter.</li>
+     * </ul>
      */
     public void setLineWrapMode(LineWrapMode wrap) {
         if (block.getLineWrapMode() != wrap) {
@@ -436,28 +507,38 @@ public class BitmapText extends Node {
         }
     }
 
+    /**
+     * Assembles the text by generating the quad list (character positions and sizes)
+     * and then populating the vertex buffers of each `BitmapTextPage`.
+     * This method is called internally when `needRefresh` is true.
+     */
     private void assemble() {
-        // first generate quad list
+        // First, generate or update the list of letter quads
+        // based on current text and layout properties.
         letters.update();
-        for (int i = 0; i < textPages.length; i++) {
-            textPages[i].assemble(letters);
+        // Then, for each font page, assemble its mesh data from the generated quads.
+        for (BitmapTextPage textPage : textPages) {
+            textPage.assemble(letters);
         }
         needRefresh = false;
     }
 
+    /**
+     * Renders the `BitmapText` spatial. This method iterates through each
+     * `BitmapTextPage`, sets its texture, and renders it using the provided
+     * `RenderManager`.
+     *
+     * @param rm The `RenderManager` responsible for drawing.
+     * @param color The base color to apply during rendering. Note that colors
+     * set per-substring will override this for those parts.
+     */
     public void render(RenderManager rm, ColorRGBA color) {
         for (BitmapTextPage page : textPages) {
             Material mat = page.getMaterial();
             mat.setTexture("ColorMap", page.getTexture());
-            //ColorRGBA original = getColor(mat, "Color");
-            //mat.setColor("Color", color);
+            // mat.setColor("Color", color); // If the material supports a "Color" parameter
             mat.render(page, rm);
-
-            //if( original == null ) {
-            //    mat.clearParam("Color");
-            //} else {
-            //    mat.setColor("Color", original);
-            //}
         }
     }
+
 }

+ 4 - 4
jme3-core/src/main/java/com/jme3/material/RenderState.java

@@ -1629,9 +1629,9 @@ public class RenderState implements Cloneable, Savable {
             state.backStencilFunction = additionalState.backStencilFunction;
 
             state.frontStencilMask = additionalState.frontStencilMask;
-            state.frontStencilReference = additionalState.frontStencilMask;
+            state.frontStencilReference = additionalState.frontStencilReference;
             state.backStencilMask = additionalState.backStencilMask;
-            state.backStencilReference = additionalState.backStencilMask;
+            state.backStencilReference = additionalState.backStencilReference;
         } else {
             state.stencilTest = stencilTest;
 
@@ -1647,9 +1647,9 @@ public class RenderState implements Cloneable, Savable {
             state.backStencilFunction = backStencilFunction;
 
             state.frontStencilMask = frontStencilMask;
-            state.frontStencilReference = frontStencilMask;
+            state.frontStencilReference = frontStencilReference;
             state.backStencilMask = backStencilMask;
-            state.backStencilReference = backStencilMask;
+            state.backStencilReference = backStencilReference;
         }
         if (additionalState.applyLineWidth) {
             state.lineWidth = additionalState.lineWidth;

+ 2 - 2
jme3-core/src/main/java/com/jme3/renderer/RenderManager.java

@@ -1357,8 +1357,8 @@ public class RenderManager {
         }
 
         // cleanup for used render pipelines and pipeline contexts only
-        for (PipelineContext c : usedContexts) {
-            c.endContextRenderFrame(this);
+        for (int i = 0; i < usedContexts.size(); i++) {
+            usedContexts.get(i).endContextRenderFrame(this);
         }
         for (RenderPipeline<?> p : usedPipelines) {
             p.endRenderFrame(this);

+ 22 - 16
jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureDebugAppState.java

@@ -121,7 +121,7 @@ public class ArmatureDebugAppState extends BaseAppState {
     }
 
     private void registerInput() {
-        inputManager.addMapping(PICK_JOINT, new MouseButtonTrigger(MouseInput.BUTTON_LEFT), new MouseButtonTrigger(MouseInput.BUTTON_RIGHT));
+        inputManager.addMapping(PICK_JOINT, new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
         inputManager.addMapping(TOGGLE_JOINTS, new KeyTrigger(KeyInput.KEY_F10));
         inputManager.addListener(actionListener, PICK_JOINT, TOGGLE_JOINTS);
     }
@@ -248,6 +248,10 @@ public class ArmatureDebugAppState extends BaseAppState {
 
         @Override
         public void onAction(String name, boolean isPressed, float tpf) {
+            if (!isEnabled()) {
+                return;
+            }
+
             if (name.equals(PICK_JOINT)) {
                 if (isPressed) {
                     // Start counting click delay on mouse press
@@ -290,22 +294,24 @@ public class ArmatureDebugAppState extends BaseAppState {
             }
         }
 
-        private void printJointInfo(Joint selectedjoint, ArmatureDebugger ad) {
+        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());
+                String info = "\n-----------------------\n" +
+                        "Selected Joint : " + selectedJoint.getName() + " in armature " + ad.getName() + "\n" +
+                        "Root Bone : " + (selectedJoint.getParent() == null) + "\n" +
+                        "-----------------------\n" +
+                        "Local translation: " + selectedJoint.getLocalTranslation() + "\n" +
+                        "Local rotation: " + selectedJoint.getLocalRotation() + "\n" +
+                        "Local scale: " + selectedJoint.getLocalScale() + "\n" +
+                        "---\n" +
+                        "Model translation: " + selectedJoint.getModelTransform().getTranslation() + "\n" +
+                        "Model rotation: " + selectedJoint.getModelTransform().getRotation() + "\n" +
+                        "Model scale: " + selectedJoint.getModelTransform().getScale() + "\n" +
+                        "---\n" +
+                        "Bind inverse Transform: \n" +
+                        selectedJoint.getInverseModelBindMatrix();
+
+                logger.log(Level.INFO, info);
             }
         }
 

+ 41 - 44
jme3-core/src/main/java/com/jme3/shadow/PointLightShadowRenderer.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
@@ -51,33 +51,39 @@ import com.jme3.util.clone.Cloner;
 import java.io.IOException;
 
 /**
- * PointLightShadowRenderer renders shadows for a point light
+ * Renders shadows for a {@link PointLight}. This renderer uses six cameras,
+ * one for each face of a cube map, to capture shadows from the point light's
+ * perspective.
  *
  * @author Rémy Bouquet aka Nehon
  */
 public class PointLightShadowRenderer extends AbstractShadowRenderer {
 
+    /**
+     * The fixed number of cameras used for rendering point light shadows (6 for a cube map).
+     */
     public static final int CAM_NUMBER = 6;
+
     protected PointLight light;
     protected Camera[] shadowCams;
-    private Geometry[] frustums = null;
+    protected Geometry[] frustums = null;
+    protected final Vector3f X_NEG = Vector3f.UNIT_X.mult(-1f);
+    protected final Vector3f Y_NEG = Vector3f.UNIT_Y.mult(-1f);
+    protected final Vector3f Z_NEG = Vector3f.UNIT_Z.mult(-1f);
 
     /**
-     * Used for serialization.
-     * Use PointLightShadowRenderer#PointLightShadowRenderer(AssetManager
-     * assetManager, int shadowMapSize)
-     * instead.
+     * For serialization only. Do not use.
      */
     protected PointLightShadowRenderer() {
         super();
     }
 
     /**
-     * Creates a PointLightShadowRenderer
+     * Creates a new {@code PointLightShadowRenderer} instance.
      *
-     * @param assetManager the application asset manager
-     * @param shadowMapSize the size of the rendered shadowmaps (512,1024,2048,
-     * etc...)
+     * @param assetManager The application's asset manager.
+     * @param shadowMapSize The size of the rendered shadow maps (e.g., 512, 1024, 2048).
+     * Higher values produce better quality shadows but may impact performance.
      */
     public PointLightShadowRenderer(AssetManager assetManager, int shadowMapSize) {
         super(assetManager, shadowMapSize, CAM_NUMBER);
@@ -86,7 +92,7 @@ public class PointLightShadowRenderer extends AbstractShadowRenderer {
 
     private void init(int shadowMapSize) {
         shadowCams = new Camera[CAM_NUMBER];
-        for (int i = 0; i < CAM_NUMBER; i++) {
+        for (int i = 0; i < shadowCams.length; i++) {
             shadowCams[i] = new Camera(shadowMapSize, shadowMapSize);
         }
     }
@@ -95,9 +101,9 @@ public class PointLightShadowRenderer extends AbstractShadowRenderer {
     protected void initFrustumCam() {
         Camera viewCam = viewPort.getCamera();
         frustumCam = viewCam.clone();
-        frustumCam.setFrustum(viewCam.getFrustumNear(), zFarOverride, viewCam.getFrustumLeft(), viewCam.getFrustumRight(), viewCam.getFrustumTop(), viewCam.getFrustumBottom());
+        frustumCam.setFrustum(viewCam.getFrustumNear(), zFarOverride,
+                viewCam.getFrustumLeft(), viewCam.getFrustumRight(), viewCam.getFrustumTop(), viewCam.getFrustumBottom());
     }
-    
 
     @Override
     protected void updateShadowCams(Camera viewCam) {
@@ -107,31 +113,21 @@ public class PointLightShadowRenderer extends AbstractShadowRenderer {
             return;
         }
 
-        //bottom
-        shadowCams[0].setAxes(Vector3f.UNIT_X.mult(-1f), Vector3f.UNIT_Z.mult(-1f), Vector3f.UNIT_Y.mult(-1f));
-
-        //top
-        shadowCams[1].setAxes(Vector3f.UNIT_X.mult(-1f), Vector3f.UNIT_Z, Vector3f.UNIT_Y);
-
-        //forward
-        shadowCams[2].setAxes(Vector3f.UNIT_X.mult(-1f), Vector3f.UNIT_Y, Vector3f.UNIT_Z.mult(-1f));
-
-        //backward
-        shadowCams[3].setAxes(Vector3f.UNIT_X, Vector3f.UNIT_Y, Vector3f.UNIT_Z);
-
-        //left
-        shadowCams[4].setAxes(Vector3f.UNIT_Z, Vector3f.UNIT_Y, Vector3f.UNIT_X.mult(-1f));
-
-        //right
-        shadowCams[5].setAxes(Vector3f.UNIT_Z.mult(-1f), Vector3f.UNIT_Y, Vector3f.UNIT_X);
-
-        for (int i = 0; i < CAM_NUMBER; i++) {
-            shadowCams[i].setFrustumPerspective(90f, 1f, 0.1f, light.getRadius());
-            shadowCams[i].setLocation(light.getPosition());
-            shadowCams[i].update();
-            shadowCams[i].updateViewProjection();
+        // Configure axes for each of the six cube map cameras (positive/negative X, Y, Z)
+        shadowCams[0].setAxes(X_NEG, Z_NEG, Y_NEG);                                 // -Y (bottom)
+        shadowCams[1].setAxes(X_NEG, Vector3f.UNIT_Z, Vector3f.UNIT_Y);             // +Y (top)
+        shadowCams[2].setAxes(X_NEG, Vector3f.UNIT_Y, Z_NEG);                       // +Z (forward)
+        shadowCams[3].setAxes(Vector3f.UNIT_X, Vector3f.UNIT_Y, Vector3f.UNIT_Z);   // -Z (backward)
+        shadowCams[4].setAxes(Vector3f.UNIT_Z, Vector3f.UNIT_Y, X_NEG);             // -X (left)
+        shadowCams[5].setAxes(Z_NEG, Vector3f.UNIT_Y, Vector3f.UNIT_X);             // +X (right)
+
+        // Set perspective and location for all shadow cameras
+        for (Camera shadowCam : shadowCams) {
+            shadowCam.setFrustumPerspective(90f, 1f, 0.1f, light.getRadius());
+            shadowCam.setLocation(light.getPosition());
+            shadowCam.update();
+            shadowCam.updateViewProjection();
         }
-
     }
 
     @Override
@@ -160,7 +156,7 @@ public class PointLightShadowRenderer extends AbstractShadowRenderer {
         if (frustums == null) {
             frustums = new Geometry[CAM_NUMBER];
             Vector3f[] points = new Vector3f[8];
-            for (int i = 0; i < 8; i++) {
+            for (int i = 0; i < points.length; i++) {
                 points[i] = new Vector3f();
             }
             for (int i = 0; i < CAM_NUMBER; i++) {
@@ -168,8 +164,9 @@ public class PointLightShadowRenderer extends AbstractShadowRenderer {
                 frustums[i] = createFrustum(points, i);
             }
         }
-        if (frustums[shadowMapIndex].getParent() == null) {
-            ((Node) viewPort.getScenes().get(0)).attachChild(frustums[shadowMapIndex]);
+        Geometry geo = frustums[shadowMapIndex];
+        if (geo.getParent() == null) {
+            ((Node) viewPort.getScenes().get(0)).attachChild(geo);
         }
     }
 
@@ -237,13 +234,13 @@ public class PointLightShadowRenderer extends AbstractShadowRenderer {
         }
 
         Camera cam = viewCam;
-        if(frustumCam != null){
-            cam = frustumCam;            
+        if (frustumCam != null) {
+            cam = frustumCam;
             cam.setLocation(viewCam.getLocation());
             cam.setRotation(viewCam.getRotation());
         }
         TempVars vars = TempVars.get();
-        boolean intersects = light.intersectsFrustum(cam,vars);
+        boolean intersects = light.intersectsFrustum(cam, vars);
         vars.release();
         return intersects;
     }

+ 46 - 45
jme3-core/src/plugins/java/com/jme3/export/binary/BinaryExporter.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,7 +38,13 @@ import com.jme3.export.OutputCapsule;
 import com.jme3.export.Savable;
 import com.jme3.export.SavableClassUtil;
 import com.jme3.math.FastMath;
-import java.io.*;
+
+import java.io.BufferedOutputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.IdentityHashMap;
@@ -116,39 +122,40 @@ import java.util.logging.Logger;
  *
  * @author Joshua Slack
  */
-
 public class BinaryExporter implements JmeExporter {
-    private static final Logger logger = Logger.getLogger(BinaryExporter.class
-            .getName());
+
+    private static final Logger logger = Logger.getLogger(BinaryExporter.class.getName());
 
     protected int aliasCount = 1;
     protected int idCount = 1;
 
-    private final IdentityHashMap<Savable, BinaryIdContentPair> contentTable
-             = new IdentityHashMap<>();
-
-    protected HashMap<Integer, Integer> locationTable
-             = new HashMap<>();
+    private final IdentityHashMap<Savable, BinaryIdContentPair> contentTable = new IdentityHashMap<>();
+    protected HashMap<Integer, Integer> locationTable = new HashMap<>();
 
     // key - class name, value = bco
-    private final HashMap<String, BinaryClassObject> classes
-             = new HashMap<>();
-
+    private final HashMap<String, BinaryClassObject> classes = new HashMap<>();
     private final ArrayList<Savable> contentKeys = new ArrayList<>();
 
     public static boolean debug = false;
     public static boolean useFastBufs = true;
 
+    /**
+     * Constructs a new {@code BinaryExporter}.
+     */
     public BinaryExporter() {
     }
 
+    /**
+     * Returns a new instance of {@code BinaryExporter}.
+     *
+     * @return A new {@code BinaryExporter} instance.
+     */
     public static BinaryExporter getInstance() {
         return new BinaryExporter();
     }
 
     /**
      * Saves the object into memory then loads it from memory.
-     *
      * Used by tests to check if the persistence system is working.
      *
      * @param <T> The type of savable.
@@ -163,9 +170,11 @@ public class BinaryExporter implements JmeExporter {
         try {
             BinaryExporter exporter = new BinaryExporter();
             exporter.save(object, baos);
+
             BinaryImporter importer = new BinaryImporter();
             importer.setAssetManager(assetManager);
             return (T) importer.load(baos.toByteArray());
+
         } catch (IOException ex) {
             // Should never happen.
             throw new AssertionError(ex);
@@ -191,28 +200,25 @@ public class BinaryExporter implements JmeExporter {
         // write out tag table
         int classTableSize = 0;
         int classNum = classes.keySet().size();
-        int aliasSize = ((int) FastMath.log(classNum, 256) + 1); // make all
-                                                                  // aliases a
-                                                                  // fixed width
+        int aliasSize = ((int) FastMath.log(classNum, 256) + 1); // make all aliases a fixed width
 
         os.write(ByteUtils.convertToBytes(classNum)); // 3. "number of classes"
         for (String key : classes.keySet()) {
             BinaryClassObject bco = classes.get(key);
 
             // write alias
-            byte[] aliasBytes = fixClassAlias(bco.alias,
-                    aliasSize);
+            byte[] aliasBytes = fixClassAlias(bco.alias, aliasSize);
             os.write(aliasBytes);                     // 4. "class alias"
             classTableSize += aliasSize;
 
             // jME3 NEW: Write class hierarchy version numbers
-            os.write( bco.classHierarchyVersions.length );
-            for (int version : bco.classHierarchyVersions){
+            os.write(bco.classHierarchyVersions.length);
+            for (int version : bco.classHierarchyVersions) {
                 os.write(ByteUtils.convertToBytes(version));
             }
             classTableSize += 1 + bco.classHierarchyVersions.length * 4;
 
-            // write classname size & classname
+            // write class name size & class name
             byte[] classBytes = key.getBytes();
             os.write(ByteUtils.convertToBytes(classBytes.length)); // 5. "full class-name size"
             os.write(classBytes);                                  // 6. "full class name"
@@ -236,14 +242,12 @@ public class BinaryExporter implements JmeExporter {
         // write out data to a separate stream
         int location = 0;
         // keep track of location for each piece
-        HashMap<String, ArrayList<BinaryIdContentPair>> alreadySaved = new HashMap<>(
-                contentTable.size());
+        HashMap<String, ArrayList<BinaryIdContentPair>> alreadySaved = new HashMap<>(contentTable.size());
         for (Savable savable : contentKeys) {
             // look back at previous written data for matches
             String savableName = savable.getClass().getName();
             BinaryIdContentPair pair = contentTable.get(savable);
-            ArrayList<BinaryIdContentPair> bucket = alreadySaved
-                    .get(savableName + getChunk(pair));
+            ArrayList<BinaryIdContentPair> bucket = alreadySaved.get(savableName + getChunk(pair));
             int prevLoc = findPrevMatch(pair, bucket);
             if (prevLoc != -1) {
                 locationTable.put(pair.getId(), prevLoc);
@@ -286,17 +290,14 @@ public class BinaryExporter implements JmeExporter {
         // append stream to the output stream
         out.writeTo(os);
 
-
-        out = null;
-        os = null;
-
         if (debug) {
-            logger.fine("Stats:");
-            logger.log(Level.FINE, "classes: {0}", classNum);
-            logger.log(Level.FINE, "class table: {0} bytes", classTableSize);
-            logger.log(Level.FINE, "objects: {0}", numLocations);
-            logger.log(Level.FINE, "location table: {0} bytes", locationTableSize);
-            logger.log(Level.FINE, "data: {0} bytes", location);
+            logger.log(Level.INFO, "BinaryExporter Stats:"
+                    + "\n * Classes: {0}"
+                    + "\n * Class Table: {1} bytes"
+                    + "\n * Objects: {2}"
+                    + "\n * Location Table: {3} bytes"
+                    + "\n * Data: {4} bytes",
+                    new Object[] {classNum, classTableSize, numLocations, locationTableSize, location});
         }
     }
 
@@ -305,14 +306,15 @@ public class BinaryExporter implements JmeExporter {
                 .getContent().bytes.length));
     }
 
-    private int findPrevMatch(BinaryIdContentPair oldPair,
-            ArrayList<BinaryIdContentPair> bucket) {
-        if (bucket == null)
+    private int findPrevMatch(BinaryIdContentPair oldPair, ArrayList<BinaryIdContentPair> bucket) {
+        if (bucket == null) {
             return -1;
+        }
         for (int x = bucket.size(); --x >= 0;) {
             BinaryIdContentPair pair = bucket.get(x);
-            if (pair.getContent().equals(oldPair.getContent()))
+            if (pair.getContent().equals(oldPair.getContent())) {
                 return locationTable.get(pair.getId());
+            }
         }
         return -1;
     }
@@ -345,7 +347,7 @@ public class BinaryExporter implements JmeExporter {
         return contentTable.get(object).getContent();
     }
 
-    private BinaryClassObject createClassObject(Class<? extends Savable> clazz) throws IOException{
+    private BinaryClassObject createClassObject(Class<? extends Savable> clazz) throws IOException {
         BinaryClassObject bco = new BinaryClassObject();
         bco.alias = generateTag();
         bco.nameFields = new HashMap<>();
@@ -361,10 +363,10 @@ public class BinaryExporter implements JmeExporter {
             return -1;
         }
         Class<? extends Savable> clazz = object.getClass();
-        BinaryClassObject bco = classes.get(object.getClass().getName());
+        BinaryClassObject bco = classes.get(clazz.getName());
         // is this class been looked at before? in tagTable?
         if (bco == null) {
-            bco = createClassObject(object.getClass());
+            bco = createClassObject(clazz);
         }
 
         // is object in contentTable?
@@ -379,7 +381,6 @@ public class BinaryExporter implements JmeExporter {
         object.write(this);
         newPair.getContent().finish();
         return newPair.getId();
-
     }
 
     protected byte[] generateTag() {
@@ -401,4 +402,4 @@ public class BinaryExporter implements JmeExporter {
                 new BinaryOutputCapsule(this, bco));
         return pair;
     }
-}
+}

+ 1 - 0
jme3-plugins/src/gltf/java/com/jme3/scene/plugins/gltf/LightsPunctualExtensionLoader.java

@@ -209,6 +209,7 @@ public class LightsPunctualExtensionLoader implements ExtensionLoader {
             Light light = lightDefinitions.get(lightIndex);
             parent.addLight(light);
             LightControl control = new LightControl(light);
+            control.setInvertAxisDirection(true);
             node.addControl(control);
         } else {
             throw new AssetLoadException("KHR_lights_punctual extension accessed undefined light at index " + lightIndex);

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

@@ -36,6 +36,8 @@ import com.jme3.app.state.AppState;
 import com.jme3.app.state.VideoRecorderAppState;
 import com.jme3.math.ColorRGBA;
 
+import java.util.function.Consumer;
+
 /**
  * The app used for the tests. AppState(s) are used to inject the actual test code.
  * @author Richard Tingle (aka richtea)
@@ -46,10 +48,17 @@ public class App extends SimpleApplication {
         super(initialStates);
     }
 
+    Consumer<Throwable> onError = (onError) -> {};
+
     @Override
     public void simpleInitApp(){
         getViewPort().setBackgroundColor(ColorRGBA.Black);
         setTimer(new VideoRecorderAppState.IsoTimer(60));
     }
 
+    @Override
+    public void handleError(String errMsg, Throwable t) {
+        super.handleError(errMsg, t);
+        onError.accept(t);
+    }
 }

+ 10 - 4
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ExtentReportExtension.java

@@ -50,7 +50,7 @@ import java.util.Optional;
  */
 public class ExtentReportExtension implements BeforeAllCallback, AfterAllCallback, TestWatcher, BeforeTestExecutionCallback{
     private static ExtentReports extent;
-    private static final ThreadLocal<ExtentTest> test = new ThreadLocal<>();
+    private static ExtentTest currentTest;
 
     @Override
     public void beforeAll(ExtensionContext context) {
@@ -62,6 +62,8 @@ public class ExtentReportExtension implements BeforeAllCallback, AfterAllCallbac
             extent = new ExtentReports();
             extent.attachReporter(spark);
         }
+        // Initialize log capture to redirect console output to the report
+        ExtentReportLogCapture.initialize();
     }
 
     @Override
@@ -71,6 +73,9 @@ public class ExtentReportExtension implements BeforeAllCallback, AfterAllCallbac
         * anywhere else I can hook into the lifecycle of the end of all tests to write the report.
         */
         extent.flush();
+
+        // Restore the original System.out
+        ExtentReportLogCapture.restore();
     }
 
     @Override
@@ -96,10 +101,11 @@ public class ExtentReportExtension implements BeforeAllCallback, AfterAllCallbac
     @Override
     public void beforeTestExecution(ExtensionContext context) {
         String testName = context.getDisplayName();
-        test.set(extent.createTest(testName));
+        String className = context.getRequiredTestClass().getSimpleName();
+        currentTest = extent.createTest(className + "." + testName);
     }
 
     public static ExtentTest getCurrentTest() {
-        return test.get();
+        return currentTest;
     }
-}
+}

+ 117 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ExtentReportLogCapture.java

@@ -0,0 +1,117 @@
+/*
+ * 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.testframework;
+
+import com.aventstack.extentreports.ExtentTest;
+
+import java.io.OutputStream;
+import java.io.PrintStream;
+
+/**
+ * This class captures console logs and adds them to the ExtentReport.
+ * It redirects System.out to both the original console and the ExtentReport.
+ *
+ * @author Richard Tingle (aka richtea)
+ */
+public class ExtentReportLogCapture {
+
+    private static final PrintStream originalOut = System.out;
+    private static final PrintStream originalErr = System.err;
+    private static boolean initialized = false;
+
+    /**
+     * Initializes the log capture system. This should be called once at the start of the test suite.
+     */
+    public static void initialize() {
+        if (!initialized) {
+            // Redirect System.out and System.err
+            System.setOut(new ExtentReportPrintStream(originalOut));
+            System.setErr(new ExtentReportPrintStream(originalErr));
+
+            initialized = true;
+        }
+    }
+
+    /**
+     * Restores the original System.out. This should be called at the end of the test suite.
+     */
+    public static void restore() {
+        if(initialized) {
+            // Restore System.out and System.err
+            System.setOut(originalOut);
+            System.setErr(originalErr);
+            initialized = false;
+        }
+    }
+
+    /**
+     * A custom PrintStream that redirects output to both the original console and the ExtentReport.
+     */
+    private static class ExtentReportPrintStream extends PrintStream {
+        private StringBuilder buffer = new StringBuilder();
+
+        public ExtentReportPrintStream(OutputStream out) {
+            super(out, true);
+        }
+
+        @Override
+        public void write(byte[] buf, int off, int len) {
+            super.write(buf, off, len);
+
+            // Convert the byte array to a string and add to buffer
+            String s = new String(buf, off, len);
+            buffer.append(s);
+
+            // If we have a complete line (ends with newline), process it
+            if (s.endsWith("\n") || s.endsWith("\r\n")) {
+                String line = buffer.toString().trim();
+                if (!line.isEmpty()) {
+                    addToExtentReport(line);
+                }
+                buffer.setLength(0); // Clear the buffer
+            }
+        }
+
+        private void addToExtentReport(String s) {
+            try {
+                ExtentTest currentTest = ExtentReportExtension.getCurrentTest();
+                if (currentTest != null) {
+                    currentTest.info(s);
+                }
+            } catch (Exception e) {
+                // If there's an error adding to the report, just continue
+                // This ensures that console logs are still displayed even if there's an issue with the report
+            }
+        }
+    }
+
+}

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

@@ -57,8 +57,12 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.Executor;
 import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+import java.util.logging.Logger;
 import java.util.stream.Stream;
 
 import static org.junit.jupiter.api.Assertions.fail;
@@ -72,6 +76,8 @@ import static org.junit.jupiter.api.Assertions.fail;
  */
 public class TestDriver extends BaseAppState{
 
+    private static final Logger logger = Logger.getLogger(TestDriver.class.getName());
+
     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.";
@@ -94,7 +100,7 @@ public class TestDriver extends BaseAppState{
 
     ScreenshotNoInputAppState screenshotAppState;
 
-    private final Object waitLock = new Object();
+    private CountDownLatch waitLatch;
 
     private final int tickToTerminateApp;
 
@@ -113,15 +119,19 @@ public class TestDriver extends BaseAppState{
         }
         if(tick >= tickToTerminateApp){
             getApplication().stop(true);
-            synchronized (waitLock) {
-                waitLock.notify(); // Release the wait
-            }
+            waitLatch.countDown();
         }
 
         tick++;
     }
 
-    @Override protected void initialize(Application app){}
+    @Override protected void initialize(Application app){
+        ((App)app).onError = error -> {
+            logger.log(Level.WARNING, "Error in test application", error);
+            waitLatch.countDown();
+        };
+
+    }
 
     @Override protected void cleanup(Application app){}
 
@@ -129,7 +139,6 @@ public class TestDriver extends BaseAppState{
 
     @Override protected void onDisable(){}
 
-
     /**
      * Boots up the application on a separate thread (blocks this thread) and then does the following:
      * - Takes screenshots on the requested frames
@@ -161,16 +170,23 @@ public class TestDriver extends BaseAppState{
         app.setSettings(appSettings);
         app.setShowSettings(false);
 
+        testDriver.waitLatch = new CountDownLatch(1);
         executor.execute(() -> app.start(JmeContext.Type.Display));
 
-        synchronized (testDriver.waitLock) {
-            try {
-                testDriver.waitLock.wait(10000); // Wait for the screenshot to be taken and application to stop
-                Thread.sleep(200); //give time for openGL is fully released before starting a new test (get random JVM crashes without this)
-            } catch (InterruptedException e) {
-                Thread.currentThread().interrupt();
-                throw new RuntimeException(e);
+        int maxWaitTimeMilliseconds = 45000;
+
+        try {
+            boolean exitedProperly = testDriver.waitLatch.await(maxWaitTimeMilliseconds, TimeUnit.MILLISECONDS);
+
+            if(!exitedProperly){
+                logger.warning("Test driver did not exit in " + maxWaitTimeMilliseconds + "ms. Timed out");
+                app.stop(true);
             }
+
+            Thread.sleep(1000); //give time for openGL is fully released before starting a new test (get random JVM crashes without this)
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException(e);
         }
 
         //search the imageTempDir

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


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


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


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


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


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


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


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


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


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


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


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


+ 1 - 1
natives-snapshot.properties

@@ -1 +1 @@
-natives.snapshot=89000af21c0dabaad04815086c9b42e543e3a4dd
+natives.snapshot=c67259e3689cd8531761d189a0dc2ba86c95c767