Browse Source

Merge branch 'jMonkeyEngine:master' into master

joliver82 1 week ago
parent
commit
ab76ff510d
22 changed files with 1272 additions and 238 deletions
  1. 19 1
      .github/workflows/main.yml
  2. 1 1
      README.md
  3. 97 6
      jme3-android-native/openalsoft.gradle
  4. 1 0
      jme3-android-native/src/native/jme_bufferallocator/Android.mk
  5. 1 1
      jme3-android-native/src/native/jme_bufferallocator/Application.mk
  6. 1 1
      jme3-android-native/src/native/jme_decode/Android.mk
  7. 1 1
      jme3-android-native/src/native/jme_decode/Application.mk
  8. 1 0
      jme3-android-native/src/native/jme_decode/com_jme3_audio_plugins_NativeVorbisFile.c
  9. 40 94
      jme3-android-native/src/native/jme_openalsoft/Android.mk
  10. 1 1
      jme3-android-native/src/native/jme_openalsoft/Application.mk
  11. 13 0
      jme3-core/src/main/java/com/jme3/app/SimpleApplication.java
  12. 120 130
      jme3-core/src/main/java/com/jme3/renderer/RenderManager.java
  13. 4 0
      jme3-core/src/main/java/com/jme3/scene/Geometry.java
  14. 9 0
      jme3-core/src/main/java/com/jme3/scene/Node.java
  15. 37 1
      jme3-core/src/main/java/com/jme3/scene/Spatial.java
  16. 7 0
      jme3-core/src/main/java/com/jme3/scene/threadwarden/IllegalThreadSceneGraphMutation.java
  17. 160 0
      jme3-core/src/main/java/com/jme3/scene/threadwarden/SceneGraphThreadWarden.java
  18. 32 0
      jme3-core/src/test/java/com/jme3/scene/SpatialTest.java
  19. 207 0
      jme3-core/src/test/java/com/jme3/scene/threadwarden/SceneGraphThreadWardenGeometryExtendedTest.java
  20. 203 0
      jme3-core/src/test/java/com/jme3/scene/threadwarden/SceneGraphThreadWardenNodeExtendedTest.java
  21. 316 0
      jme3-core/src/test/java/com/jme3/scene/threadwarden/SceneGraphThreadWardenTest.java
  22. 1 1
      natives-snapshot.properties

+ 19 - 1
.github/workflows/main.yml

@@ -99,17 +99,35 @@ jobs:
     name: Build natives for android
     name: Build natives for android
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     container:
     container:
-      image: jmonkeyengine/buildenv-jme3:android
+      image: ghcr.io/cirruslabs/android-sdk:35-ndk
 
 
     steps:
     steps:
       - name: Clone the repo
       - name: Clone the repo
         uses: actions/checkout@v4
         uses: actions/checkout@v4
         with:
         with:
           fetch-depth: 1
           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
       - name: Validate the Gradle wrapper
         uses: gradle/actions/wrapper-validation@v3
         uses: gradle/actions/wrapper-validation@v3
+
       - name: Build
       - name: Build
         run: |
         run: |
+          export ANDROID_NDK="$ANDROID_SDK_ROOT/ndk/$ANDROID_NDK_VERSION"
           ./gradlew -PuseCommitHashAsVersionName=true --no-daemon -PbuildNativeProjects=true \
           ./gradlew -PuseCommitHashAsVersionName=true --no-daemon -PbuildNativeProjects=true \
           :jme3-android-native:assemble
           :jme3-android-native:assemble
 
 

+ 1 - 1
README.md

@@ -4,7 +4,7 @@ jMonkeyEngine
 [![Build Status](https://github.com/jMonkeyEngine/jmonkeyengine/workflows/Build%20jMonkeyEngine/badge.svg)](https://github.com/jMonkeyEngine/jmonkeyengine/actions)
 [![Build Status](https://github.com/jMonkeyEngine/jmonkeyengine/workflows/Build%20jMonkeyEngine/badge.svg)](https://github.com/jMonkeyEngine/jmonkeyengine/actions)
 
 
 jMonkeyEngine is a 3-D game engine for adventurous Java developers. It’s open-source, cross-platform, and cutting-edge.
 jMonkeyEngine is a 3-D game engine for adventurous Java developers. It’s open-source, cross-platform, and cutting-edge.
-v3.7.0 is the latest stable version of the engine.
+v3.8.0 is the latest stable version of the engine.
 
 
 The engine is used by several commercial game studios and computer-science courses. Here's a taste:
 The engine is used by several commercial game studios and computer-science courses. Here's a taste:
 
 

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

@@ -1,12 +1,12 @@
 // OpenAL Soft r1.21.1
 // OpenAL Soft r1.21.1
 // TODO: update URL to jMonkeyEngine fork once it's updated with latest kcat's changes
 // 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'
 String openALSoftZipFile = 'OpenALSoft.zip'
 
 
 // OpenAL Soft directory the download is extracted into
 // OpenAL Soft directory the download is extracted into
 // Typically, the downloaded OpenAL Soft zip file will extract to a directory
 // Typically, the downloaded OpenAL Soft zip file will extract to a directory
 // called "openal-soft"
 // called "openal-soft"
-String openALSoftFolder = 'openal-soft-1.21.1'
+String openALSoftFolder = 'openal-soft-1.24.3'
 
 
 //Working directories for the ndk build.
 //Working directories for the ndk build.
 String openalsoftBuildDir = "${buildDir}" + File.separator + 'openalsoft'
 String openalsoftBuildDir = "${buildDir}" + File.separator + 'openalsoft'
@@ -81,13 +81,103 @@ task copyJmeOpenALSoft(type: Copy, dependsOn: [copyOpenALSoft, copyJmeHeadersOpe
     from sourceDir
     from sourceDir
     into outputDir
     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
     workingDir openalsoftBuildDir
+
+    // call the NDK build script
     executable rootProject.ndkCommandPath
     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) {
 task updatePreCompiledOpenAlSoftLibs(type: Copy, dependsOn: buildOpenAlSoftNativeLib) {
@@ -140,3 +230,4 @@ class MyDownload extends DefaultTask {
        ant.get(src: sourceUrl, dest: target)
        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)
 include $(CLEAR_VARS)
 
 
+LOCAL_CFLAGS := -DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=true
 LOCAL_LDLIBS     := -llog -Wl,-s
 LOCAL_LDLIBS     := -llog -Wl,-s
 
 
 LOCAL_MODULE := bufferallocatorjme
 LOCAL_MODULE := bufferallocatorjme

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

@@ -36,4 +36,4 @@
 APP_PLATFORM := android-19
 APP_PLATFORM := android-19
 # change this to 'debug' to see android logs
 # change this to 'debug' to see android logs
 APP_OPTIM := release
 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) \
 		$(LOCAL_PATH)/Tremor
 		$(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
 LOCAL_LDLIBS := -lz -llog -Wl,-s
 	
 	
 ifeq ($(TARGET_ARCH),arm)
 ifeq ($(TARGET_ARCH),arm)

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

@@ -1,3 +1,3 @@
 APP_PLATFORM := android-9
 APP_PLATFORM := android-9
 APP_OPTIM := release
 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 <unistd.h>
 #include <stdlib.h>
 #include <stdlib.h>
 #include <errno.h>
 #include <errno.h>
+#include <string.h>
 
 
 #include "Tremor/ivorbisfile.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)
 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)
 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_PLATFORM := android-19
 APP_OPTIM := release
 APP_OPTIM := release
-APP_ABI := all
+APP_ABI := armeabi-v7a,arm64-v8a,x86,x86_64
 APP_STL := c++_static
 APP_STL := c++_static
 
 

+ 13 - 0
jme3-core/src/main/java/com/jme3/app/SimpleApplication.java

@@ -45,6 +45,7 @@ import com.jme3.renderer.RenderManager;
 import com.jme3.renderer.queue.RenderQueue.Bucket;
 import com.jme3.renderer.queue.RenderQueue.Bucket;
 import com.jme3.scene.Node;
 import com.jme3.scene.Node;
 import com.jme3.scene.Spatial.CullHint;
 import com.jme3.scene.Spatial.CullHint;
+import com.jme3.scene.threadwarden.SceneGraphThreadWarden;
 import com.jme3.system.AppSettings;
 import com.jme3.system.AppSettings;
 import com.jme3.system.JmeContext.Type;
 import com.jme3.system.JmeContext.Type;
 import com.jme3.system.JmeSystem;
 import com.jme3.system.JmeSystem;
@@ -197,6 +198,11 @@ public abstract class SimpleApplication extends LegacyApplication {
     public void initialize() {
     public void initialize() {
         super.initialize();
         super.initialize();
 
 
+        //noinspection AssertWithSideEffects
+        assert SceneGraphThreadWarden.setup(rootNode);
+        //noinspection AssertWithSideEffects
+        assert SceneGraphThreadWarden.setup(guiNode);
+
         // Several things rely on having this
         // Several things rely on having this
         guiFont = loadGuiFont();
         guiFont = loadGuiFont();
 
 
@@ -240,6 +246,13 @@ public abstract class SimpleApplication extends LegacyApplication {
         simpleInitApp();
         simpleInitApp();
     }
     }
 
 
+    @Override
+    public void stop(boolean waitFor) {
+        //noinspection AssertWithSideEffects
+        assert SceneGraphThreadWarden.reset();
+        super.stop(waitFor);
+    }
+
     @Override
     @Override
     public void update() {
     public void update() {
         if (prof != null) {
         if (prof != null) {

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

@@ -1,5 +1,5 @@
 /*
 /*
- * Copyright (c) 2024 jMonkeyEngine
+ * Copyright (c) 2025 jMonkeyEngine
  * All rights reserved.
  * All rights reserved.
  *
  *
  * Redistribution and use in source and binary forms, with or without
  * Redistribution and use in source and binary forms, with or without
@@ -77,37 +77,30 @@ import java.util.function.Supplier;
 import java.util.logging.Logger;
 import java.util.logging.Logger;
 
 
 /**
 /**
- * A high-level rendering interface that is
- * above the Renderer implementation. RenderManager takes care
- * of rendering the scene graphs attached to each viewport and
- * handling SceneProcessors.
- *
- * @see SceneProcessor
- * @see ViewPort
- * @see Spatial
+ * The `RenderManager` is a high-level rendering interface that manages
+ * {@link ViewPort}s, {@link SceneProcessor}s, and the overall rendering pipeline.
+ * It is responsible for orchestrating the rendering of scenes into various
+ * viewports.
  */
  */
 public class RenderManager {
 public class RenderManager {
 
 
     private static final Logger logger = Logger.getLogger(RenderManager.class.getName());
     private static final Logger logger = Logger.getLogger(RenderManager.class.getName());
+
     private final Renderer renderer;
     private final Renderer renderer;
     private final UniformBindingManager uniformBindingManager = new UniformBindingManager();
     private final UniformBindingManager uniformBindingManager = new UniformBindingManager();
     private final ArrayList<ViewPort> preViewPorts = new ArrayList<>();
     private final ArrayList<ViewPort> preViewPorts = new ArrayList<>();
     private final ArrayList<ViewPort> viewPorts = new ArrayList<>();
     private final ArrayList<ViewPort> viewPorts = new ArrayList<>();
     private final ArrayList<ViewPort> postViewPorts = new ArrayList<>();
     private final ArrayList<ViewPort> postViewPorts = new ArrayList<>();
-    private final HashMap<Class, PipelineContext> contexts = new HashMap<>();
-    private final ArrayList<PipelineContext> usedContexts = new ArrayList<>();
-    private final ArrayList<RenderPipeline> usedPipelines = new ArrayList<>();
-    private RenderPipeline defaultPipeline = new ForwardPipeline();
+    private final HashMap<Class<? extends PipelineContext>, PipelineContext> contexts = new HashMap<>();
+    private final LinkedList<PipelineContext> usedContexts = new LinkedList<>();
+    private final LinkedList<RenderPipeline<? extends PipelineContext>> usedPipelines = new LinkedList<>();
+    private RenderPipeline<? extends PipelineContext> defaultPipeline = new ForwardPipeline();
     private Camera prevCam = null;
     private Camera prevCam = null;
     private Material forcedMaterial = null;
     private Material forcedMaterial = null;
     private String forcedTechnique = null;
     private String forcedTechnique = null;
     private RenderState forcedRenderState = null;
     private RenderState forcedRenderState = null;
-    private final SafeArrayList<MatParamOverride> forcedOverrides
-            = new SafeArrayList<>(MatParamOverride.class);
-    private int viewX;
-    private int viewY;
-    private int viewWidth;
-    private int viewHeight;
+    private final SafeArrayList<MatParamOverride> forcedOverrides = new SafeArrayList<>(MatParamOverride.class);
+
     private final Matrix4f orthoMatrix = new Matrix4f();
     private final Matrix4f orthoMatrix = new Matrix4f();
     private final LightList filteredLightList = new LightList(null);
     private final LightList filteredLightList = new LightList(null);
     private boolean handleTranslucentBucket = true;
     private boolean handleTranslucentBucket = true;
@@ -115,7 +108,7 @@ public class RenderManager {
     private LightFilter lightFilter = new DefaultLightFilter();
     private LightFilter lightFilter = new DefaultLightFilter();
     private TechniqueDef.LightMode preferredLightMode = TechniqueDef.LightMode.MultiPass;
     private TechniqueDef.LightMode preferredLightMode = TechniqueDef.LightMode.MultiPass;
     private int singlePassLightBatchSize = 1;
     private int singlePassLightBatchSize = 1;
-    private MatParamOverride boundDrawBufferId=new MatParamOverride(VarType.Int, "BoundDrawBuffer", 0);
+    private final MatParamOverride boundDrawBufferId = new MatParamOverride(VarType.Int, "BoundDrawBuffer", 0);
     private Predicate<Geometry> renderFilter;
     private Predicate<Geometry> renderFilter;
 
 
 
 
@@ -123,7 +116,7 @@ public class RenderManager {
      * Creates a high-level rendering interface over the
      * Creates a high-level rendering interface over the
      * low-level rendering interface.
      * low-level rendering interface.
      *
      *
-     * @param renderer (alias created)
+     * @param renderer The low-level renderer implementation.
      */
      */
     public RenderManager(Renderer renderer) {
     public RenderManager(Renderer renderer) {
         this.renderer = renderer;
         this.renderer = renderer;
@@ -131,59 +124,61 @@ public class RenderManager {
         // register default pipeline context
         // register default pipeline context
         contexts.put(PipelineContext.class, new DefaultPipelineContext());
         contexts.put(PipelineContext.class, new DefaultPipelineContext());
     }
     }
-    
+
     /**
     /**
      * Gets the default pipeline used when a ViewPort does not have a
      * Gets the default pipeline used when a ViewPort does not have a
      * pipeline already assigned to it.
      * pipeline already assigned to it.
-     * 
-     * @return 
+     *
+     * @return The default {@link RenderPipeline}, which is {@link ForwardPipeline} by default.
      */
      */
-    public RenderPipeline getPipeline() {
+    public RenderPipeline<? extends PipelineContext> getPipeline() {
         return defaultPipeline;
         return defaultPipeline;
     }
     }
-    
+
     /**
     /**
      * Sets the default pipeline used when a ViewPort does not have a
      * Sets the default pipeline used when a ViewPort does not have a
      * pipeline already assigned to it.
      * pipeline already assigned to it.
      * <p>
      * <p>
      * default={@link ForwardPipeline}
      * default={@link ForwardPipeline}
-     * 
-     * @param pipeline default pipeline (not null)
+     *
+     * @param pipeline The default rendering pipeline (not null).
      */
      */
-    public void setPipeline(RenderPipeline pipeline) {
+    public void setPipeline(RenderPipeline<? extends PipelineContext> pipeline) {
         assert pipeline != null;
         assert pipeline != null;
         this.defaultPipeline = pipeline;
         this.defaultPipeline = pipeline;
     }
     }
-    
+
     /**
     /**
      * Gets the default pipeline context registered under
      * Gets the default pipeline context registered under
-     * {@link PipelineContext#getClass()}.
-     * 
-     * @return 
+     * {@link PipelineContext}.
+     *
+     * @return The default {@link PipelineContext}.
      */
      */
     public PipelineContext getDefaultContext() {
     public PipelineContext getDefaultContext() {
         return getContext(PipelineContext.class);
         return getContext(PipelineContext.class);
     }
     }
-    
+
     /**
     /**
-     * Gets the pipeline context registered under the class.
-     * 
-     * @param <T>
-     * @param type
-     * @return registered context or null
+     * Gets the pipeline context registered under the given class type.
+     *
+     * @param type The class type of the context to retrieve.
+     * @param <T>  The type of the {@link PipelineContext}.
+     * @return The registered context instance, or null if not found.
      */
      */
+    @SuppressWarnings("unchecked")
     public <T extends PipelineContext> T getContext(Class<T> type) {
     public <T extends PipelineContext> T getContext(Class<T> type) {
-        return (T)contexts.get(type);
+        return (T) contexts.get(type);
     }
     }
-    
+
     /**
     /**
      * Gets the pipeline context registered under the class or creates
      * Gets the pipeline context registered under the class or creates
      * and registers a new context from the supplier.
      * and registers a new context from the supplier.
-     * 
-     * @param <T>
-     * @param type
-     * @param supplier interface for creating a new context if necessary
-     * @return registered or newly created context
+     *
+     * @param <T>      The type of the {@link PipelineContext}.
+     * @param type     The class type under which the context is registered.
+     * @param supplier A function interface for creating a new context
+     *                 if one is not already registered under the given type.
+     * @return The registered or newly created context.
      */
      */
     public <T extends PipelineContext> T getOrCreateContext(Class<T> type, Supplier<T> supplier) {
     public <T extends PipelineContext> T getOrCreateContext(Class<T> type, Supplier<T> supplier) {
         T c = getContext(type);
         T c = getContext(type);
@@ -193,15 +188,16 @@ public class RenderManager {
         }
         }
         return c;
         return c;
     }
     }
-    
+
     /**
     /**
      * Gets the pipeline context registered under the class or creates
      * Gets the pipeline context registered under the class or creates
      * and registers a new context from the function.
      * and registers a new context from the function.
-     * 
-     * @param <T>
-     * @param type
-     * @param function interface for creating a new context if necessary
-     * @return registered or newly created context
+     *
+     * @param <T>      The type of the {@link PipelineContext}.
+     * @param type     The class type under which the context is registered.
+     * @param function A function interface for creating a new context, taking the {@code RenderManager} as an argument,
+     *                 if one is not already registered under the given type.
+     * @return The registered or newly created context.
      */
      */
     public <T extends PipelineContext> T getOrCreateContext(Class<T> type, Function<RenderManager, T> function) {
     public <T extends PipelineContext> T getOrCreateContext(Class<T> type, Function<RenderManager, T> function) {
         T c = getContext(type);
         T c = getContext(type);
@@ -211,16 +207,16 @@ public class RenderManager {
         }
         }
         return c;
         return c;
     }
     }
-    
+
     /**
     /**
-     * Registers the pipeline context under the class.
+     * Registers a pipeline context under the given class type.
      * <p>
      * <p>
      * If another context is already registered under the class, that
      * If another context is already registered under the class, that
      * context will be replaced by the given context.
      * context will be replaced by the given context.
-     * 
-     * @param <T>
-     * @param type class type to register the context under (not null)
-     * @param context context to register (not null)
+     *
+     * @param type    The class type under which the context is registered.
+     * @param context The context instance to register.
+     * @param <T>     The type of the {@link PipelineContext}.
      */
      */
     public <T extends PipelineContext> void registerContext(Class<T> type, T context) {
     public <T extends PipelineContext> void registerContext(Class<T> type, T context) {
         assert type != null;
         assert type != null;
@@ -229,11 +225,11 @@ public class RenderManager {
         }
         }
         contexts.put(type, context);
         contexts.put(type, context);
     }
     }
-    
+
     /**
     /**
      * Gets the application profiler.
      * Gets the application profiler.
-     * 
-     * @return 
+     *
+     * @return The {@link AppProfiler} instance, or null if none is set.
      */
      */
     public AppProfiler getProfiler() {
     public AppProfiler getProfiler() {
         return prof;
         return prof;
@@ -522,7 +518,7 @@ public class RenderManager {
         for (ViewPort vp : preViewPorts) {
         for (ViewPort vp : preViewPorts) {
             notifyRescale(vp, x, y);
             notifyRescale(vp, x, y);
         }
         }
-        for (ViewPort vp : viewPorts) {      
+        for (ViewPort vp : viewPorts) {
             notifyRescale(vp, x, y);
             notifyRescale(vp, x, y);
         }
         }
         for (ViewPort vp : postViewPorts) {
         for (ViewPort vp : postViewPorts) {
@@ -531,22 +527,19 @@ public class RenderManager {
     }
     }
 
 
     /**
     /**
-     * Sets the material to use to render all future objects.
-     * This overrides the material set on the geometry and renders
-     * with the provided material instead.
-     * Use null to clear the material and return renderer to normal
-     * functionality.
+     * Sets a material that will be forced on all rendered geometries.
+     * This can be used for debugging (e.g., solid color) or special effects.
      *
      *
-     * @param mat The forced material to set, or null to return to normal
+     * @param forcedMaterial The material to force, or null to disable forcing.
      */
      */
-    public void setForcedMaterial(Material mat) {
-        forcedMaterial = mat;
+    public void setForcedMaterial(Material forcedMaterial) {
+        this.forcedMaterial = forcedMaterial;
     }
     }
-    
+
     /**
     /**
-     * Gets the forced material.
-     * 
-     * @return 
+     * Gets the forced material that overrides materials set on geometries.
+     *
+     * @return The forced {@link Material}, or null if no material is forced.
      */
      */
     public Material getForcedMaterial() {
     public Material getForcedMaterial() {
         return forcedMaterial;
         return forcedMaterial;
@@ -597,10 +590,9 @@ public class RenderManager {
     }
     }
 
 
     /**
     /**
-     * Returns the forced technique name set.
-     *
-     * @return the forced technique name set.
+     * Returns the name of the forced technique.
      *
      *
+     * @return The name of the forced technique, or null if none is forced.
      * @see #setForcedTechnique(java.lang.String)
      * @see #setForcedTechnique(java.lang.String)
      */
      */
     public String getForcedTechnique() {
     public String getForcedTechnique() {
@@ -616,9 +608,7 @@ public class RenderManager {
      * If a forced material is not set and the forced technique name cannot
      * If a forced material is not set and the forced technique name cannot
      * be found on the material, the geometry will <em>not</em> be rendered.
      * be found on the material, the geometry will <em>not</em> be rendered.
      *
      *
-     * @param forcedTechnique The forced technique name to use, set to null
-     *     to return to normal functionality.
-     *
+     * @param forcedTechnique The technique to force, or null to disable forcing.
      * @see #renderGeometry(com.jme3.scene.Geometry)
      * @see #renderGeometry(com.jme3.scene.Geometry)
      */
      */
     public void setForcedTechnique(String forcedTechnique) {
     public void setForcedTechnique(String forcedTechnique) {
@@ -627,13 +617,12 @@ public class RenderManager {
 
 
     /**
     /**
      * Adds a forced material parameter to use when rendering geometries.
      * Adds a forced material parameter to use when rendering geometries.
-     *
-     * <p>The provided parameter takes precedence over parameters set on the
+     * <p>
+     * The provided parameter takes precedence over parameters set on the
      * material or any overrides that exist in the scene graph that have the
      * material or any overrides that exist in the scene graph that have the
      * same name.
      * same name.
      *
      *
-     * @param override The override to add
-     * @see MatParamOverride
+     * @param override The material parameter override to add.
      * @see #removeForcedMatParam(com.jme3.material.MatParamOverride)
      * @see #removeForcedMatParam(com.jme3.material.MatParamOverride)
      */
      */
     public void addForcedMatParam(MatParamOverride override) {
     public void addForcedMatParam(MatParamOverride override) {
@@ -641,9 +630,9 @@ public class RenderManager {
     }
     }
 
 
     /**
     /**
-     * Removes a forced material parameter previously added.
+     * Removes a material parameter override.
      *
      *
-     * @param override The override to remove.
+     * @param override The material parameter override to remove.
      * @see #addForcedMatParam(com.jme3.material.MatParamOverride)
      * @see #addForcedMatParam(com.jme3.material.MatParamOverride)
      */
      */
     public void removeForcedMatParam(MatParamOverride override) {
     public void removeForcedMatParam(MatParamOverride override) {
@@ -757,33 +746,35 @@ public class RenderManager {
      * @see com.jme3.material.Material#render(com.jme3.scene.Geometry, com.jme3.renderer.RenderManager)
      * @see com.jme3.material.Material#render(com.jme3.scene.Geometry, com.jme3.renderer.RenderManager)
      */
      */
     public void renderGeometry(Geometry geom) {
     public void renderGeometry(Geometry geom) {
-        
+
         if (renderFilter != null && !renderFilter.test(geom)) {
         if (renderFilter != null && !renderFilter.test(geom)) {
             return;
             return;
         }
         }
-        
+
         LightList lightList = geom.getWorldLightList();
         LightList lightList = geom.getWorldLightList();
         if (lightFilter != null) {
         if (lightFilter != null) {
             filteredLightList.clear();
             filteredLightList.clear();
             lightFilter.filterLights(geom, filteredLightList);
             lightFilter.filterLights(geom, filteredLightList);
             lightList = filteredLightList;
             lightList = filteredLightList;
         }
         }
-        
+
         renderGeometry(geom, lightList);
         renderGeometry(geom, lightList);
-        
     }
     }
-    
+
     /**
     /**
-     * 
-     * @param geom
-     * @param lightList 
+     * Renders a single {@link Geometry} with a specific list of lights.
+     * This method applies the world transform, handles forced materials and techniques,
+     * and manages the `BoundDrawBuffer` parameter for multi-target frame buffers.
+     *
+     * @param geom The {@link Geometry} to render.
+     * @param lightList The {@link LightList} containing the lights that affect this geometry.
      */
      */
     public void renderGeometry(Geometry geom, LightList lightList) {
     public void renderGeometry(Geometry geom, LightList lightList) {
-        
+
         if (renderFilter != null && !renderFilter.test(geom)) {
         if (renderFilter != null && !renderFilter.test(geom)) {
             return;
             return;
         }
         }
-        
+
         this.renderer.pushDebugGroup(geom.getName());
         this.renderer.pushDebugGroup(geom.getName());
         if (geom.isIgnoreTransform()) {
         if (geom.isIgnoreTransform()) {
             setWorldMatrix(Matrix4f.IDENTITY);
             setWorldMatrix(Matrix4f.IDENTITY);
@@ -818,8 +809,7 @@ public class RenderManager {
                 RenderState tmpRs = forcedRenderState;
                 RenderState tmpRs = forcedRenderState;
                 if (geom.getMaterial().getActiveTechnique().getDef().getForcedRenderState() != null) {
                 if (geom.getMaterial().getActiveTechnique().getDef().getForcedRenderState() != null) {
                     //forcing forced technique renderState
                     //forcing forced technique renderState
-                    forcedRenderState
-                            = geom.getMaterial().getActiveTechnique().getDef().getForcedRenderState();
+                    forcedRenderState = geom.getMaterial().getActiveTechnique().getDef().getForcedRenderState();
                 }
                 }
                 // use geometry's material
                 // use geometry's material
                 material.render(geom, lightList, this);
                 material.render(geom, lightList, this);
@@ -902,7 +892,7 @@ public class RenderManager {
             }
             }
         }
         }
     }
     }
-    
+
     /**
     /**
      * Flattens the given scene graph into the ViewPort's RenderQueue,
      * Flattens the given scene graph into the ViewPort's RenderQueue,
      * checking for culling as the call goes down the graph recursively.
      * checking for culling as the call goes down the graph recursively.
@@ -1079,10 +1069,9 @@ public class RenderManager {
      */
      */
     public void setSinglePassLightBatchSize(int singlePassLightBatchSize) {
     public void setSinglePassLightBatchSize(int singlePassLightBatchSize) {
         // Ensure the batch size is no less than 1
         // Ensure the batch size is no less than 1
-        this.singlePassLightBatchSize = singlePassLightBatchSize < 1 ? 1 : singlePassLightBatchSize;
+        this.singlePassLightBatchSize = Math.max(singlePassLightBatchSize, 1);
     }
     }
 
 
-
     /**
     /**
      * Renders the given viewport queues.
      * Renders the given viewport queues.
      *
      *
@@ -1126,7 +1115,6 @@ public class RenderManager {
             depthRangeChanged = true;
             depthRangeChanged = true;
         }
         }
 
 
-
         // transparent objects are last because they require blending with the
         // transparent objects are last because they require blending with the
         // rest of the scene's objects. Consequently, they are sorted
         // rest of the scene's objects. Consequently, they are sorted
         // back-to-front.
         // back-to-front.
@@ -1184,12 +1172,12 @@ public class RenderManager {
     private void setViewPort(Camera cam) {
     private void setViewPort(Camera cam) {
         // this will make sure to clearReservations viewport only if needed
         // this will make sure to clearReservations viewport only if needed
         if (cam != prevCam || cam.isViewportChanged()) {
         if (cam != prevCam || cam.isViewportChanged()) {
-            viewX      = (int) (cam.getViewPortLeft() * cam.getWidth());
-            viewY      = (int) (cam.getViewPortBottom() * cam.getHeight());
+            int viewX  = (int) (cam.getViewPortLeft() * cam.getWidth());
+            int viewY  = (int) (cam.getViewPortBottom() * cam.getHeight());
             int viewX2 = (int) (cam.getViewPortRight() * cam.getWidth());
             int viewX2 = (int) (cam.getViewPortRight() * cam.getWidth());
             int viewY2 = (int) (cam.getViewPortTop() * cam.getHeight());
             int viewY2 = (int) (cam.getViewPortTop() * cam.getHeight());
-            viewWidth  = viewX2 - viewX;
-            viewHeight = viewY2 - viewY;
+            int viewWidth  = viewX2 - viewX;
+            int viewHeight = viewY2 - viewY;
             uniformBindingManager.setViewPort(viewX, viewY, viewWidth, viewHeight);
             uniformBindingManager.setViewPort(viewX, viewY, viewWidth, viewHeight);
             renderer.setViewPort(viewX, viewY, viewWidth, viewHeight);
             renderer.setViewPort(viewX, viewY, viewWidth, viewHeight);
             renderer.setClipRect(viewX, viewY, viewWidth, viewHeight);
             renderer.setClipRect(viewX, viewY, viewWidth, viewHeight);
@@ -1266,8 +1254,8 @@ public class RenderManager {
     /**
     /**
      * Applies the ViewPort's Camera and FrameBuffer in preparation
      * Applies the ViewPort's Camera and FrameBuffer in preparation
      * for rendering.
      * for rendering.
-     * 
-     * @param vp 
+     *
+     * @param vp The ViewPort to apply.
      */
      */
     public void applyViewPort(ViewPort vp) {
     public void applyViewPort(ViewPort vp) {
         renderer.setFrameBuffer(vp.getOutputFrameBuffer());
         renderer.setFrameBuffer(vp.getOutputFrameBuffer());
@@ -1279,7 +1267,7 @@ public class RenderManager {
             renderer.clearBuffers(vp.isClearColor(), vp.isClearDepth(), vp.isClearStencil());
             renderer.clearBuffers(vp.isClearColor(), vp.isClearDepth(), vp.isClearStencil());
         }
         }
     }
     }
-    
+
     /**
     /**
      * Renders the {@link ViewPort} using the ViewPort's {@link RenderPipeline}.
      * Renders the {@link ViewPort} using the ViewPort's {@link RenderPipeline}.
      * <p>
      * <p>
@@ -1294,11 +1282,12 @@ public class RenderManager {
     public void renderViewPort(ViewPort vp, float tpf) {
     public void renderViewPort(ViewPort vp, float tpf) {
         if (!vp.isEnabled()) {
         if (!vp.isEnabled()) {
             return;
             return;
-        }        
+        }
         RenderPipeline pipeline = vp.getPipeline();
         RenderPipeline pipeline = vp.getPipeline();
         if (pipeline == null) {
         if (pipeline == null) {
             pipeline = defaultPipeline;
             pipeline = defaultPipeline;
         }
         }
+
         PipelineContext context = pipeline.fetchPipelineContext(this);
         PipelineContext context = pipeline.fetchPipelineContext(this);
         if (context == null) {
         if (context == null) {
             throw new NullPointerException("Failed to fetch pipeline context.");
             throw new NullPointerException("Failed to fetch pipeline context.");
@@ -1310,6 +1299,7 @@ public class RenderManager {
             usedPipelines.add(pipeline);
             usedPipelines.add(pipeline);
             pipeline.startRenderFrame(this);
             pipeline.startRenderFrame(this);
         }
         }
+
         pipeline.pipelineRender(this, context, vp, tpf);
         pipeline.pipelineRender(this, context, vp, tpf);
         context.endViewPortRender(this, vp);
         context.endViewPortRender(this, vp);
     }
     }
@@ -1333,7 +1323,7 @@ public class RenderManager {
         if (renderer instanceof NullRenderer) {
         if (renderer instanceof NullRenderer) {
             return;
             return;
         }
         }
-        
+
         uniformBindingManager.newFrame();
         uniformBindingManager.newFrame();
 
 
         if (prof != null) {
         if (prof != null) {
@@ -1365,17 +1355,16 @@ public class RenderManager {
                 renderViewPort(vp, tpf);
                 renderViewPort(vp, tpf);
             }
             }
         }
         }
-        
+
         // cleanup for used render pipelines and pipeline contexts only
         // cleanup for used render pipelines and pipeline contexts only
         for (int i = 0; i < usedContexts.size(); i++) {
         for (int i = 0; i < usedContexts.size(); i++) {
             usedContexts.get(i).endContextRenderFrame(this);
             usedContexts.get(i).endContextRenderFrame(this);
         }
         }
-        for (int i = 0; i < usedPipelines.size(); i++) {
-            usedPipelines.get(i).endRenderFrame(this);
+        for (RenderPipeline<?> p : usedPipelines) {
+            p.endRenderFrame(this);
         }
         }
         usedContexts.clear();
         usedContexts.clear();
         usedPipelines.clear();
         usedPipelines.clear();
-        
     }
     }
 
 
     /**
     /**
@@ -1384,41 +1373,42 @@ public class RenderManager {
      * @return True if the draw buffer target id is passed to the shaders.
      * @return True if the draw buffer target id is passed to the shaders.
      */
      */
     public boolean getPassDrawBufferTargetIdToShaders() {
     public boolean getPassDrawBufferTargetIdToShaders() {
-        return this.forcedOverrides.contains(boundDrawBufferId);
+        return forcedOverrides.contains(boundDrawBufferId);
     }
     }
 
 
     /**
     /**
      * Enable or disable passing the draw buffer target id to the shaders. This
      * Enable or disable passing the draw buffer target id to the shaders. This
      * is needed to handle FrameBuffer.setTargetIndex correctly in some
      * is needed to handle FrameBuffer.setTargetIndex correctly in some
-     * backends.
+     * backends. When enabled, a material parameter named "BoundDrawBuffer" of
+     * type Int will be added to forced material parameters.
      *
      *
-     * @param v
-     *            True to enable, false to disable (default is true)
+     * @param enable True to enable, false to disable (default is true)
      */
      */
-    public void setPassDrawBufferTargetIdToShaders(boolean v) {
-        if (v) {
-            if (!this.forcedOverrides.contains(boundDrawBufferId)) {
-                this.forcedOverrides.add(boundDrawBufferId);
+    public void setPassDrawBufferTargetIdToShaders(boolean enable) {
+        if (enable) {
+            if (!forcedOverrides.contains(boundDrawBufferId)) {
+                forcedOverrides.add(boundDrawBufferId);
             }
             }
         } else {
         } else {
-            this.forcedOverrides.remove(boundDrawBufferId);
+            forcedOverrides.remove(boundDrawBufferId);
         }
         }
     }
     }
-    
+
     /**
     /**
      * Set a render filter. Every geometry will be tested against this filter
      * Set a render filter. Every geometry will be tested against this filter
      * before rendering and will only be rendered if the filter returns true.
      * before rendering and will only be rendered if the filter returns true.
-     * 
-     * @param filter the render filter
+     * This allows for custom culling or selective rendering based on geometry properties.
+     *
+     * @param filter The render filter to apply, or null to remove any existing filter.
      */
      */
     public void setRenderFilter(Predicate<Geometry> filter) {
     public void setRenderFilter(Predicate<Geometry> filter) {
         renderFilter = filter;
         renderFilter = filter;
     }
     }
 
 
     /**
     /**
-     * Returns the render filter that the RenderManager is currently using
-     * 
-     * @return the render filter 
+     * Returns the render filter that the RenderManager is currently using.
+     *
+     * @return The currently active render filter, or null if no filter is set.
      */
      */
     public Predicate<Geometry> getRenderFilter() {
     public Predicate<Geometry> getRenderFilter() {
         return renderFilter;
         return renderFilter;

+ 4 - 0
jme3-core/src/main/java/com/jme3/scene/Geometry.java

@@ -44,6 +44,7 @@ import com.jme3.math.Matrix4f;
 import com.jme3.renderer.Camera;
 import com.jme3.renderer.Camera;
 import com.jme3.scene.VertexBuffer.Type;
 import com.jme3.scene.VertexBuffer.Type;
 import com.jme3.scene.mesh.MorphTarget;
 import com.jme3.scene.mesh.MorphTarget;
+import com.jme3.scene.threadwarden.SceneGraphThreadWarden;
 import com.jme3.util.TempVars;
 import com.jme3.util.TempVars;
 import com.jme3.util.clone.Cloner;
 import com.jme3.util.clone.Cloner;
 import com.jme3.util.clone.IdentityCloneFunction;
 import com.jme3.util.clone.IdentityCloneFunction;
@@ -183,6 +184,7 @@ public class Geometry extends Spatial {
      */
      */
     @Override
     @Override
     public void setLodLevel(int lod) {
     public void setLodLevel(int lod) {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         if (mesh.getNumLodLevels() == 0) {
         if (mesh.getNumLodLevels() == 0) {
             throw new IllegalStateException("LOD levels are not set on this mesh");
             throw new IllegalStateException("LOD levels are not set on this mesh");
         }
         }
@@ -239,6 +241,7 @@ public class Geometry extends Spatial {
      * @throws IllegalArgumentException If mesh is null
      * @throws IllegalArgumentException If mesh is null
      */
      */
     public void setMesh(Mesh mesh) {
     public void setMesh(Mesh mesh) {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         if (mesh == null) {
         if (mesh == null) {
             throw new IllegalArgumentException();
             throw new IllegalArgumentException();
         }
         }
@@ -269,6 +272,7 @@ public class Geometry extends Spatial {
      */
      */
     @Override
     @Override
     public void setMaterial(Material material) {
     public void setMaterial(Material material) {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         this.material = material;
         this.material = material;
         nbSimultaneousGPUMorph = -1;
         nbSimultaneousGPUMorph = -1;
         if (isGrouped()) {
         if (isGrouped()) {

+ 9 - 0
jme3-core/src/main/java/com/jme3/scene/Node.java

@@ -38,6 +38,7 @@ import com.jme3.collision.CollisionResults;
 import com.jme3.export.JmeExporter;
 import com.jme3.export.JmeExporter;
 import com.jme3.export.JmeImporter;
 import com.jme3.export.JmeImporter;
 import com.jme3.material.Material;
 import com.jme3.material.Material;
+import com.jme3.scene.threadwarden.SceneGraphThreadWarden;
 import com.jme3.util.SafeArrayList;
 import com.jme3.util.SafeArrayList;
 import com.jme3.util.clone.Cloner;
 import com.jme3.util.clone.Cloner;
 import java.io.IOException;
 import java.io.IOException;
@@ -201,6 +202,7 @@ public class Node extends Spatial {
      *  that would change state.
      *  that would change state.
      */
      */
     void invalidateUpdateList() {
     void invalidateUpdateList() {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         updateListValid = false;
         updateListValid = false;
         if (parent != null) {
         if (parent != null) {
             parent.invalidateUpdateList();
             parent.invalidateUpdateList();
@@ -344,6 +346,7 @@ public class Node extends Spatial {
      * @throws IllegalArgumentException if child is null or this
      * @throws IllegalArgumentException if child is null or this
      */
      */
     public int attachChildAt(Spatial child, int index) {
     public int attachChildAt(Spatial child, int index) {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         if (child == null) {
         if (child == null) {
             throw new IllegalArgumentException("child cannot be null");
             throw new IllegalArgumentException("child cannot be null");
         }
         }
@@ -428,6 +431,7 @@ public class Node extends Spatial {
      * @return the child at the supplied index.
      * @return the child at the supplied index.
      */
      */
     public Spatial detachChildAt(int index) {
     public Spatial detachChildAt(int index) {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         Spatial child = children.remove(index);
         Spatial child = children.remove(index);
         if (child != null) {
         if (child != null) {
             child.setParent(null);
             child.setParent(null);
@@ -455,6 +459,7 @@ public class Node extends Spatial {
      * node.
      * node.
      */
      */
     public void detachAllChildren() {
     public void detachAllChildren() {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         // Note: this could be a bit more efficient if it delegated
         // Note: this could be a bit more efficient if it delegated
         // to a private method that avoided setBoundRefresh(), etc.
         // to a private method that avoided setBoundRefresh(), etc.
         // for every child and instead did one in here at the end.
         // for every child and instead did one in here at the end.
@@ -483,6 +488,7 @@ public class Node extends Spatial {
      * @param index2 The index of the second child to swap
      * @param index2 The index of the second child to swap
      */
      */
     public void swapChildren(int index1, int index2) {
     public void swapChildren(int index1, int index2) {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         Spatial c2 = children.get(index2);
         Spatial c2 = children.get(index2);
         Spatial c1 = children.remove(index1);
         Spatial c1 = children.remove(index1);
         children.add(index1, c2);
         children.add(index1, c2);
@@ -562,6 +568,7 @@ public class Node extends Spatial {
 
 
     @Override
     @Override
     public void setMaterial(Material mat) {
     public void setMaterial(Material mat) {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         for (int i = 0; i < children.size(); i++) {
         for (int i = 0; i < children.size(); i++) {
             children.get(i).setMaterial(mat);
             children.get(i).setMaterial(mat);
         }
         }
@@ -778,6 +785,7 @@ public class Node extends Spatial {
 
 
     @Override
     @Override
     public void setModelBound(BoundingVolume modelBound) {
     public void setModelBound(BoundingVolume modelBound) {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         if (children != null) {
         if (children != null) {
             for (Spatial child : children.getArray()) {
             for (Spatial child : children.getArray()) {
                 child.setModelBound(modelBound != null ? modelBound.clone(null) : null);
                 child.setModelBound(modelBound != null ? modelBound.clone(null) : null);
@@ -787,6 +795,7 @@ public class Node extends Spatial {
 
 
     @Override
     @Override
     public void updateModelBound() {
     public void updateModelBound() {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         if (children != null) {
         if (children != null) {
             for (Spatial child : children.getArray()) {
             for (Spatial child : children.getArray()) {
                 child.updateModelBound();
                 child.updateModelBound();

+ 37 - 1
jme3-core/src/main/java/com/jme3/scene/Spatial.java

@@ -49,6 +49,7 @@ import com.jme3.renderer.queue.RenderQueue;
 import com.jme3.renderer.queue.RenderQueue.Bucket;
 import com.jme3.renderer.queue.RenderQueue.Bucket;
 import com.jme3.renderer.queue.RenderQueue.ShadowMode;
 import com.jme3.renderer.queue.RenderQueue.ShadowMode;
 import com.jme3.scene.control.Control;
 import com.jme3.scene.control.Control;
+import com.jme3.scene.threadwarden.SceneGraphThreadWarden;
 import com.jme3.util.SafeArrayList;
 import com.jme3.util.SafeArrayList;
 import com.jme3.util.TempVars;
 import com.jme3.util.TempVars;
 import com.jme3.util.clone.Cloner;
 import com.jme3.util.clone.Cloner;
@@ -278,11 +279,13 @@ public abstract class Spatial implements Savable, Cloneable, Collidable,
      * a refresh is required.
      * a refresh is required.
      */
      */
     protected void setTransformRefresh() {
     protected void setTransformRefresh() {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         refreshFlags |= RF_TRANSFORM;
         refreshFlags |= RF_TRANSFORM;
         setBoundRefresh();
         setBoundRefresh();
     }
     }
 
 
     protected void setLightListRefresh() {
     protected void setLightListRefresh() {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         refreshFlags |= RF_LIGHTLIST;
         refreshFlags |= RF_LIGHTLIST;
         // Make sure next updateGeometricState() visits this branch
         // Make sure next updateGeometricState() visits this branch
         // to update lights.
         // to update lights.
@@ -299,6 +302,7 @@ public abstract class Spatial implements Savable, Cloneable, Collidable,
     }
     }
 
 
     protected void setMatParamOverrideRefresh() {
     protected void setMatParamOverrideRefresh() {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         refreshFlags |= RF_MATPARAM_OVERRIDE;
         refreshFlags |= RF_MATPARAM_OVERRIDE;
         Spatial p = parent;
         Spatial p = parent;
         while (p != null) {
         while (p != null) {
@@ -316,6 +320,7 @@ public abstract class Spatial implements Savable, Cloneable, Collidable,
      * a refresh is required.
      * a refresh is required.
      */
      */
     protected void setBoundRefresh() {
     protected void setBoundRefresh() {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         refreshFlags |= RF_BOUND;
         refreshFlags |= RF_BOUND;
 
 
         Spatial p = parent;
         Spatial p = parent;
@@ -364,7 +369,8 @@ public abstract class Spatial implements Savable, Cloneable, Collidable,
             throw new IllegalStateException("Scene graph is not properly updated for rendering.\n"
             throw new IllegalStateException("Scene graph is not properly updated for rendering.\n"
                     + "State was changed after rootNode.updateGeometricState() call. \n"
                     + "State was changed after rootNode.updateGeometricState() call. \n"
                     + "Make sure you do not modify the scene from another thread!\n"
                     + "Make sure you do not modify the scene from another thread!\n"
-                    + "Problem spatial name: " + getName());
+                    + "Problem spatial name: " + getName() + "\n" +
+                    SceneGraphThreadWarden.getTurnOnAssertsPrompt());
         }
         }
 
 
         CullHint cm = getCullHint();
         CullHint cm = getCullHint();
@@ -612,6 +618,7 @@ public abstract class Spatial implements Savable, Cloneable, Collidable,
      * @see MatParamOverride
      * @see MatParamOverride
      */
      */
     public void addMatParamOverride(MatParamOverride override) {
     public void addMatParamOverride(MatParamOverride override) {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         if (override == null) {
         if (override == null) {
             throw new IllegalArgumentException("override cannot be null");
             throw new IllegalArgumentException("override cannot be null");
         }
         }
@@ -626,6 +633,7 @@ public abstract class Spatial implements Savable, Cloneable, Collidable,
      * @see MatParamOverride
      * @see MatParamOverride
      */
      */
     public void removeMatParamOverride(MatParamOverride override) {
     public void removeMatParamOverride(MatParamOverride override) {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         if (localOverrides.remove(override)) {
         if (localOverrides.remove(override)) {
             setMatParamOverrideRefresh();
             setMatParamOverrideRefresh();
         }
         }
@@ -637,6 +645,7 @@ public abstract class Spatial implements Savable, Cloneable, Collidable,
      * @see #addMatParamOverride(com.jme3.material.MatParamOverride)
      * @see #addMatParamOverride(com.jme3.material.MatParamOverride)
      */
      */
     public void clearMatParamOverrides() {
     public void clearMatParamOverrides() {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         if (!localOverrides.isEmpty()) {
         if (!localOverrides.isEmpty()) {
             setMatParamOverrideRefresh();
             setMatParamOverrideRefresh();
         }
         }
@@ -772,6 +781,7 @@ public abstract class Spatial implements Savable, Cloneable, Collidable,
      * @see Spatial#removeControl(java.lang.Class)
      * @see Spatial#removeControl(java.lang.Class)
      */
      */
     public void addControl(Control control) {
     public void addControl(Control control) {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         boolean before = requiresUpdates();
         boolean before = requiresUpdates();
         controls.add(control);
         controls.add(control);
         control.setSpatial(this);
         control.setSpatial(this);
@@ -823,6 +833,7 @@ public abstract class Spatial implements Savable, Cloneable, Collidable,
      * @see Spatial#addControl(com.jme3.scene.control.Control)
      * @see Spatial#addControl(com.jme3.scene.control.Control)
      */
      */
     public void removeControl(Class<? extends Control> controlType) {
     public void removeControl(Class<? extends Control> controlType) {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         boolean before = requiresUpdates();
         boolean before = requiresUpdates();
         for (int i = 0; i < controls.size(); i++) {
         for (int i = 0; i < controls.size(); i++) {
             if (controlType.isAssignableFrom(controls.get(i).getClass())) {
             if (controlType.isAssignableFrom(controls.get(i).getClass())) {
@@ -850,6 +861,7 @@ public abstract class Spatial implements Savable, Cloneable, Collidable,
      * @see Spatial#addControl(com.jme3.scene.control.Control)
      * @see Spatial#addControl(com.jme3.scene.control.Control)
      */
      */
     public boolean removeControl(Control control) {
     public boolean removeControl(Control control) {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
         boolean before = requiresUpdates();
         boolean before = requiresUpdates();
         boolean result = controls.remove(control);
         boolean result = controls.remove(control);
         if (result) {
         if (result) {
@@ -986,6 +998,28 @@ public abstract class Spatial implements Savable, Cloneable, Collidable,
         return worldTransform.transformInverseVector(in, store);
         return worldTransform.transformInverseVector(in, store);
     }
     }
 
 
+    /**
+     * Transforms the given quaternion from world space to local space relative to this object's transform.
+     *
+     * @param in the input quaternion in world space that needs to be transformed
+     * @param store an optional Quaternion to store the result; if null, a new Quaternion will be created
+     * @return the transformed quaternion in local space, either stored in the provided Quaternion or a new one
+     */
+    public Quaternion worldToLocal(final Quaternion in, Quaternion store){
+        checkDoTransformUpdate();
+        if(store == null){
+            store=new Quaternion(in);
+        }else{
+            store.set(in);
+        }
+        TempVars tempVars = TempVars.get();
+        Quaternion worldRotation = tempVars.quat1.set(getWorldRotation());
+        worldRotation.inverseLocal();
+        store.multLocal(worldRotation);
+        tempVars.release();
+        return store;
+    }
+
     /**
     /**
      * <code>getParent</code> retrieves this node's parent. If the parent is
      * <code>getParent</code> retrieves this node's parent. If the parent is
      * null this is the root node.
      * null this is the root node.
@@ -1005,6 +1039,7 @@ public abstract class Spatial implements Savable, Cloneable, Collidable,
      *            the parent of this node.
      *            the parent of this node.
      */
      */
     protected void setParent(Node parent) {
     protected void setParent(Node parent) {
+        assert SceneGraphThreadWarden.updateRequirement(this, parent);
         this.parent = parent;
         this.parent = parent;
     }
     }
 
 
@@ -1369,6 +1404,7 @@ public abstract class Spatial implements Savable, Cloneable, Collidable,
      * @param lod The lod level to set.
      * @param lod The lod level to set.
      */
      */
     public void setLodLevel(int lod) {
     public void setLodLevel(int lod) {
+        assert SceneGraphThreadWarden.assertOnCorrectThread(this);
     }
     }
 
 
     /**
     /**

+ 7 - 0
jme3-core/src/main/java/com/jme3/scene/threadwarden/IllegalThreadSceneGraphMutation.java

@@ -0,0 +1,7 @@
+package com.jme3.scene.threadwarden;
+
+public class IllegalThreadSceneGraphMutation extends IllegalStateException{
+    public IllegalThreadSceneGraphMutation(String message){
+        super(message);
+    }
+}

+ 160 - 0
jme3-core/src/main/java/com/jme3/scene/threadwarden/SceneGraphThreadWarden.java

@@ -0,0 +1,160 @@
+package com.jme3.scene.threadwarden;
+
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+
+import java.util.Collections;
+import java.util.Set;
+import java.util.WeakHashMap;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Thread warden keeps track of mutations to the scene graph and ensures that they are only done on the main thread.
+ * IF the parent node is marked as being reserved for the main thread (which basically means it's connected to the
+ * root node)
+ * <p>
+ *     Only has an effect if asserts are on
+ * </p>
+ */
+public class SceneGraphThreadWarden {
+
+    private static final Logger logger = Logger.getLogger(SceneGraphThreadWarden.class.getName());
+    
+    /**
+     * If THREAD_WARDEN_ENABLED is true AND asserts are on the checks are made.
+     * This parameter is here to allow asserts to run without thread warden checks (by setting this parameter to false)
+     */
+    public static boolean THREAD_WARDEN_ENABLED = !Boolean.getBoolean("nothreadwarden");
+
+    public static boolean ASSERTS_ENABLED = false;
+
+    static{
+        //noinspection AssertWithSideEffects
+        assert ASSERTS_ENABLED = true;
+    }
+
+    public static Thread mainThread;
+    public static final Set<Spatial> spatialsThatAreMainThreadReserved = Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>()));
+
+    /**
+     * Marks the given node as being reserved for the main thread.
+     * Additionally, sets the current thread as the main thread (if it hasn't already been set)
+     * @param rootNode the root node of the scene graph. This is used to determine if a spatial is a child of the root node.
+     *                 (Can add multiple "root" nodes, e.g. gui nodes or overlay nodes)
+     */
+    public static boolean setup(Node rootNode){
+        if(checksDisabled()){
+            return true;
+        }
+        Thread thisThread = Thread.currentThread();
+        if(mainThread != null && mainThread != thisThread ){
+            throw new IllegalStateException("The main thread has already been set to " + mainThread.getName() + " but now it's being set to " + Thread.currentThread().getName());
+        }
+        mainThread = thisThread;
+        setTreeRestricted(rootNode);
+
+        return true; // return true so can be a "side effect" of an assert
+    }
+
+    /**
+     * Disables the thread warden checks (even when other asserts are on).
+     * <p>
+     * Alternatively can be disabled by adding the -Dnothreadwarden=true parameter
+     * </p>
+     */
+    public static void disableChecks(){
+        THREAD_WARDEN_ENABLED = false;
+    }
+
+    /**
+     * Runs through the entire tree and sets the restriction state of all nodes below the given node
+     * @param spatial the node (and children) to set the restriction state of
+     */
+    private static void setTreeRestricted(Spatial spatial){
+        spatialsThatAreMainThreadReserved.add(spatial);
+        if(spatial instanceof Node){
+            for(Spatial child : ((Node) spatial).getChildren()){
+                setTreeRestricted(child);
+            }
+        }
+    }
+
+    /**
+     * Releases this tree from being only allowed to be mutated on the main thread
+     * @param spatial the node (and children) to release the restriction state of.
+     */
+    private static void setTreeNotRestricted(Spatial spatial){
+        spatialsThatAreMainThreadReserved.remove(spatial);
+        if(spatial instanceof Node){
+            for(Spatial child : ((Node) spatial).getChildren()){
+                setTreeNotRestricted(child);
+            }
+        }
+    }
+
+    @SuppressWarnings("SameReturnValue")
+    public static boolean updateRequirement(Spatial spatial, Node newParent){
+        if(checksDisabled()){
+            return true;
+        }
+
+        boolean shouldNowBeRestricted = newParent !=null && spatialsThatAreMainThreadReserved.contains(newParent);
+        boolean wasPreviouslyRestricted = spatialsThatAreMainThreadReserved.contains(spatial);
+
+        if(shouldNowBeRestricted || wasPreviouslyRestricted ){
+            assertOnCorrectThread(spatial);
+        }
+
+        if(shouldNowBeRestricted == wasPreviouslyRestricted){
+            return true;
+        }
+        if(shouldNowBeRestricted){
+            setTreeRestricted(spatial);
+        }else{
+            setTreeNotRestricted(spatial);
+        }
+
+        return true; // return true so can be a "side effect" of an assert
+    }
+
+    public static boolean reset(){
+        spatialsThatAreMainThreadReserved.clear();
+        mainThread = null;
+        THREAD_WARDEN_ENABLED = !Boolean.getBoolean("nothreadwarden");
+        return true; // return true so can be a "side effect" of an assert
+    }
+
+    private static boolean checksDisabled(){
+       return !THREAD_WARDEN_ENABLED || !ASSERTS_ENABLED;
+    }
+
+    @SuppressWarnings("SameReturnValue")
+    public static boolean assertOnCorrectThread(Spatial spatial){
+        if(checksDisabled()){
+            return true;
+        }
+        if(spatialsThatAreMainThreadReserved.contains(spatial)){
+            if(Thread.currentThread() != mainThread){
+                // log as well as throw an exception because we are running in a thread, if we are in an executor service the exception
+                // might not make itself known until `get` is called on the future (and JME might crash before that happens).
+                String message = "The spatial " + spatial + " was mutated on a thread other than the main thread, was mutated on " + Thread.currentThread().getName();
+                IllegalThreadSceneGraphMutation ex = new IllegalThreadSceneGraphMutation(message);
+                logger.log(Level.WARNING, message, ex);
+
+                throw ex;
+            }
+        }
+        return true; // return true so can be a "side effect" of an assert
+    }
+
+    public static String getTurnOnAssertsPrompt(){
+        if(ASSERTS_ENABLED){
+            return "";
+        } else{
+            return "To get more accurate debug consider turning on asserts. This will allow JME to do additional checks which *may* find the source of the problem. To do so, add -ea to the JVM arguments.";
+        }
+    }
+
+}
+

+ 32 - 0
jme3-core/src/test/java/com/jme3/scene/SpatialTest.java

@@ -31,6 +31,9 @@
  */
  */
 package com.jme3.scene;
 package com.jme3.scene;
 
 
+import com.jme3.math.FastMath;
+import com.jme3.math.Quaternion;
+import com.jme3.math.Vector3f;
 import com.jme3.scene.control.UpdateControl;
 import com.jme3.scene.control.UpdateControl;
 import org.junit.Assert;
 import org.junit.Assert;
 import org.junit.Test;
 import org.junit.Test;
@@ -119,4 +122,33 @@ public class SpatialTest {
         Assert.assertEquals(testSpatial, control1.getSpatial());
         Assert.assertEquals(testSpatial, control1.getSpatial());
         Assert.assertEquals(testSpatial, control2.getSpatial());
         Assert.assertEquals(testSpatial, control2.getSpatial());
     }
     }
+
+    @Test
+    public void testTransferToOtherNode(){
+        Node nodeA = new Node("nodeA");
+        Node nodeB = new Node("nodeB");
+        Node testNode=new Node("testNode");
+        nodeA.setLocalTranslation(-1,0,0);
+        nodeB.setLocalTranslation(1,0,0);
+        nodeB.rotate(0,90* FastMath.DEG_TO_RAD,0);
+        testNode.setLocalTranslation(1,0,0);
+        nodeA.attachChild(testNode);
+        Vector3f worldTranslation = testNode.getWorldTranslation().clone();
+        Quaternion worldRotation = testNode.getWorldRotation().clone();
+
+        Assert.assertTrue(worldTranslation.isSimilar(testNode.getWorldTranslation(),1e-6f));
+        Assert.assertTrue(worldRotation.isSimilar(testNode.getWorldRotation(),1e-6f));
+
+        nodeB.attachChild(testNode);
+
+        Assert.assertFalse(worldTranslation.isSimilar(testNode.getWorldTranslation(),1e-6f));
+        Assert.assertFalse(worldRotation.isSimilar(testNode.getWorldRotation(),1e-6f));
+
+        testNode.setLocalTranslation(nodeB.worldToLocal(worldTranslation,null));
+        Assert.assertTrue(worldTranslation.isSimilar(testNode.getWorldTranslation(),1e-6f));
+
+        testNode.setLocalRotation(nodeB.worldToLocal(worldRotation,null));
+        System.out.println(testNode.getWorldRotation());
+        Assert.assertTrue(worldRotation.isSimilar(testNode.getWorldRotation(),1e-6f));
+    }
 }
 }

+ 207 - 0
jme3-core/src/test/java/com/jme3/scene/threadwarden/SceneGraphThreadWardenGeometryExtendedTest.java

@@ -0,0 +1,207 @@
+package com.jme3.scene.threadwarden;
+
+import com.jme3.material.Material;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Mesh;
+import com.jme3.scene.Node;
+import com.jme3.scene.shape.Box;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.mockito.Mockito;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadFactory;
+import java.util.function.Consumer;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Parameterized tests for SceneGraphThreadWarden class with Geometry objects.
+ * These tests verify that various scene graph mutations are properly checked for thread safety.
+ */
+@RunWith(Parameterized.class)
+public class SceneGraphThreadWardenGeometryExtendedTest {
+
+    private static ExecutorService executorService;
+
+    private final String testName;
+    private final Consumer<Geometry> action;
+
+    @SuppressWarnings({"ReassignedVariable", "AssertWithSideEffects"})
+    @BeforeClass
+    public static void setupClass() {
+        // Make sure assertions are enabled
+        boolean assertsEnabled = false;
+        assert assertsEnabled = true;
+        if (!assertsEnabled) {
+            throw new RuntimeException("WARNING: Assertions are not enabled! Tests may not work correctly.");
+        }
+    }
+
+    @Before
+    public void setup() {
+        executorService = newSingleThreadDaemonExecutor();
+    }
+
+    @After
+    public void tearDown() {
+        executorService.shutdown();
+        SceneGraphThreadWarden.reset();
+    }
+
+    /**
+     * Constructor for the parameterized test.
+     * 
+     * @param testName A descriptive name for the test
+     * @param action The action to perform on the spatial
+     */
+    public SceneGraphThreadWardenGeometryExtendedTest(String testName, Consumer<Geometry> action) {
+        this.testName = testName;
+        this.action = action;
+    }
+
+    /**
+     * Define the parameters for the test.
+     * Each parameter is a pair of (test name, action to perform on spatial).
+     */
+    @Parameterized.Parameters(name = "{0}")
+    public static Collection<Object[]> data() {
+        Material mockMaterial = Mockito.mock(Material.class);
+        Box box = new Box(1, 1, 1);
+
+        return Arrays.asList(new Object[][] {
+            { 
+                "setMaterial", 
+                (Consumer<Geometry>) spatial -> spatial.setMaterial(mockMaterial)
+            },
+            { 
+                "setMesh", 
+                (Consumer<Geometry>) spatial -> spatial.setMesh(box)
+            },
+            { 
+                "setLodLevel", 
+                (Consumer<Geometry>) spatial -> {
+                    // Need to set a mesh with LOD levels first
+                    Mesh mesh = new Box(1, 1, 1);
+                    mesh.setLodLevels(new com.jme3.scene.VertexBuffer[]{
+                        mesh.getBuffer(com.jme3.scene.VertexBuffer.Type.Index)
+                    });
+                    spatial.setMesh(mesh);
+                    spatial.setLodLevel(0);
+                }
+            },
+            { 
+                "removeFromParent", 
+                (Consumer<Geometry>) Geometry::removeFromParent
+            }
+        });
+    }
+
+    /**
+     * Test that scene graph mutation is fine on the main thread when the object is attached to the root.
+     */
+    @Test
+    public void testMutationOnMainThreadOnAttachedObject() {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        // Create a geometry and attach it to the root node
+        Geometry geometry = new Geometry("geometry", new Box(1, 1, 1));
+        rootNode.attachChild(geometry);
+
+        // This should work fine since we're on the main thread
+        action.accept(geometry);
+    }
+
+    /**
+     * Test that scene graph mutation is fine on the main thread when the object is not attached to the root.
+     */
+    @Test
+    public void testMutationOnMainThreadOnDetachedObject() {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        // Create a geometry but don't attach it to the root node
+        Geometry geometry = new Geometry("geometry", new Box(1, 1, 1));
+
+        // This should work fine since we're on the main thread
+        action.accept(geometry);
+    }
+
+    /**
+     * Test that scene graph mutation is fine on a non-main thread when the object is not attached to the root.
+     */
+    @Test
+    public void testMutationOnNonMainThreadOnDetachedObject() throws ExecutionException, InterruptedException {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        // Create a geometry but don't attach it to the root node
+        Geometry geometry = new Geometry("geometry", new Box(1, 1, 1));
+
+        Future<Void> future = executorService.submit(() -> {
+            // This should work fine since the geometry is not connected to the root node
+            action.accept(geometry);
+            return null;
+        });
+
+        // This should complete without exceptions
+        future.get();
+    }
+
+    /**
+     * Test that scene graph mutation is not allowed on a non-main thread when the object is attached to the root.
+     */
+    @Test
+    public void testMutationOnNonMainThreadOnAttachedObject() throws InterruptedException {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        // Create a geometry and attach it to the root node
+        Geometry geometry = new Geometry("geometry", new Box(1, 1, 1));
+        rootNode.attachChild(geometry);
+
+        Future<Void> future = executorService.submit(() -> {
+            // This should fail because we're trying to modify a geometry that's connected to the scene graph
+            action.accept(geometry);
+            return null;
+        });
+
+        try {
+            future.get();
+            fail("Expected an IllegalThreadSceneGraphMutation exception");
+        } catch (ExecutionException e) {
+            // This is expected - verify it's the right exception type
+            assertTrue("Expected IllegalThreadSceneGraphMutation, got: " + e.getCause().getClass().getName(),
+                    e.getCause() instanceof IllegalThreadSceneGraphMutation);
+        }
+    }
+
+    /**
+     * Creates a single-threaded executor service with daemon threads.
+     */
+    private static ExecutorService newSingleThreadDaemonExecutor() {
+        return Executors.newSingleThreadExecutor(daemonThreadFactory());
+    }
+
+    /**
+     * Creates a thread factory that produces daemon threads.
+     */
+    private static ThreadFactory daemonThreadFactory() {
+        return r -> {
+            Thread t = Executors.defaultThreadFactory().newThread(r);
+            t.setDaemon(true);
+            return t;
+        };
+    }
+}

+ 203 - 0
jme3-core/src/test/java/com/jme3/scene/threadwarden/SceneGraphThreadWardenNodeExtendedTest.java

@@ -0,0 +1,203 @@
+package com.jme3.scene.threadwarden;
+
+import com.jme3.material.Material;
+import com.jme3.material.MatParamOverride;
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+import com.jme3.shader.VarType;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.mockito.Mockito;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadFactory;
+import java.util.function.Consumer;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Parameterized tests for SceneGraphThreadWarden class.
+ * These tests verify that various scene graph mutations are properly checked for thread safety.
+ */
+@RunWith(Parameterized.class)
+public class SceneGraphThreadWardenNodeExtendedTest {
+
+    private static ExecutorService executorService;
+
+    private final String testName;
+    private final Consumer<Node> action;
+
+    @SuppressWarnings({"ReassignedVariable", "AssertWithSideEffects"})
+    @BeforeClass
+    public static void setupClass() {
+        // Make sure assertions are enabled
+        boolean assertsEnabled = false;
+        assert assertsEnabled = true;
+        if (!assertsEnabled) {
+            throw new RuntimeException("WARNING: Assertions are not enabled! Tests may not work correctly.");
+        }
+    }
+
+    @Before
+    public void setup() {
+        executorService = newSingleThreadDaemonExecutor();
+    }
+
+    @After
+    public void tearDown() {
+        executorService.shutdown();
+        SceneGraphThreadWarden.reset();
+    }
+
+    /**
+     * Constructor for the parameterized test.
+     * 
+     * @param testName A descriptive name for the test
+     * @param action The action to perform on the spatial
+     */
+    public SceneGraphThreadWardenNodeExtendedTest(String testName, Consumer<Node> action) {
+        this.testName = testName;
+        this.action = action;
+    }
+
+    /**
+     * Define the parameters for the test.
+     * Each parameter is a pair of (test name, action to perform on spatial).
+     */
+    @Parameterized.Parameters(name = "{0}")
+    public static Collection<Object[]> data() {
+        Material mockMaterial = Mockito.mock(Material.class);
+        MatParamOverride override = new MatParamOverride(VarType.Float, "TestParam", 1.0f);
+
+        return Arrays.asList(new Object[][] {
+            { 
+                "setMaterial", 
+                (Consumer<Node>) spatial -> spatial.setMaterial(mockMaterial)
+            },
+            { 
+                "setLodLevel", 
+                (Consumer<Node>) spatial -> spatial.setLodLevel(1)
+            },
+            { 
+                "addMatParamOverride", 
+                (Consumer<Node>) spatial -> spatial.addMatParamOverride(override)
+            },
+            { 
+                "removeMatParamOverride", 
+                (Consumer<Node>) spatial -> spatial.removeMatParamOverride(override)
+            },
+            { 
+                "clearMatParamOverrides", 
+                (Consumer<Node>) Spatial::clearMatParamOverrides
+            }
+        });
+    }
+
+    /**
+     * Test that scene graph mutation is fine on the main thread when the object is attached to the root.
+     */
+    @Test
+    public void testMutationOnMainThreadOnAttachedObject() {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        // Create a child node and attach it to the root node
+        Node child = new Node("child");
+        rootNode.attachChild(child);
+
+        // This should work fine since we're on the main thread
+        action.accept(child);
+    }
+
+    /**
+     * Test that scene graph mutation is fine on the main thread when the object is not attached to the root.
+     */
+    @Test
+    public void testMutationOnMainThreadOnDetachedObject() {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        // Create a child node but don't attach it to the root node
+        Node child = new Node("child");
+
+        // This should work fine since we're on the main thread
+        action.accept(child);
+    }
+
+    /**
+     * Test that scene graph mutation is fine on a non-main thread when the object is not attached to the root.
+     */
+    @Test
+    public void testMutationOnNonMainThreadOnDetachedObject() throws ExecutionException, InterruptedException {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        // Create a child node but don't attach it to the root node
+        Node child = new Node("child");
+
+        Future<Void> future = executorService.submit(() -> {
+            // This should work fine since the node is not connected to the root node
+            action.accept(child);
+            return null;
+        });
+
+        // This should complete without exceptions
+        future.get();
+    }
+
+    /**
+     * Test that scene graph mutation is not allowed on a non-main thread when the object is attached to the root.
+     */
+    @Test
+    public void testMutationOnNonMainThreadOnAttachedObject() throws InterruptedException {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        // Create a child node and attach it to the root node
+        Node child = new Node("child");
+        rootNode.attachChild(child);
+
+        Future<Void> future = executorService.submit(() -> {
+            // This should fail because we're trying to modify a node that's connected to the scene graph
+            action.accept(child);
+            return null;
+        });
+
+        try {
+            future.get();
+            fail("Expected an IllegalThreadSceneGraphMutation exception");
+        } catch (ExecutionException e) {
+            // This is expected - verify it's the right exception type
+            assertTrue("Expected IllegalThreadSceneGraphMutation, got: " + e.getCause().getClass().getName(),
+                    e.getCause() instanceof IllegalThreadSceneGraphMutation);
+        }
+    }
+
+    /**
+     * Creates a single-threaded executor service with daemon threads.
+     */
+    private static ExecutorService newSingleThreadDaemonExecutor() {
+        return Executors.newSingleThreadExecutor(daemonThreadFactory());
+    }
+
+    /**
+     * Creates a thread factory that produces daemon threads.
+     */
+    private static ThreadFactory daemonThreadFactory() {
+        return r -> {
+            Thread t = Executors.defaultThreadFactory().newThread(r);
+            t.setDaemon(true);
+            return t;
+        };
+    }
+}

+ 316 - 0
jme3-core/src/test/java/com/jme3/scene/threadwarden/SceneGraphThreadWardenTest.java

@@ -0,0 +1,316 @@
+package com.jme3.scene.threadwarden;
+
+import com.jme3.scene.Node;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadFactory;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Tests for SceneGraphThreadWarden class.
+ * These tests verify that:
+ * - Normal node mutation is fine on the main thread
+ * - Node mutation on nodes not connected to the root node is fine even on a non main thread
+ * - Adding a node to the scene graph (indirectly) connected to the root node isn't fine on a non main thread
+ * - Adding a node currently attached to a root node to a different node isn't fine on a non main thread
+ */
+public class SceneGraphThreadWardenTest {
+
+    private static ExecutorService executorService;
+
+    @SuppressWarnings({"ReassignedVariable", "AssertWithSideEffects"})
+    @BeforeClass
+    public static void setupClass() {
+        // Make sure assertions are enabled
+        boolean assertsEnabled = false;
+        assert assertsEnabled = true;
+        //noinspection ConstantValue
+        if (!assertsEnabled) {
+            throw new RuntimeException("WARNING: Assertions are not enabled! Tests may not work correctly.");
+        }
+    }
+
+    @Before
+    public void setup() {
+        executorService = newSingleThreadDaemonExecutor();
+    }
+
+    @After
+    public void tearDown() {
+        executorService.shutdown();
+        SceneGraphThreadWarden.reset();
+    }
+
+    /**
+     * Test that normal node mutation is fine on the main thread.
+     */
+    @Test
+    public void testNormalNodeMutationOnMainThread() {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        // This should work fine since we're on the main thread
+        Node child = new Node("child");
+        rootNode.attachChild(child);
+
+        // Add another level of children
+        Node grandchild = new Node("grandchild");
+        child.attachChild(grandchild);
+
+        // Detach should also work fine
+        child.detachChild(grandchild);
+        rootNode.detachChild(child);
+    }
+
+    /**
+     * Test that node mutation on nodes not connected to the root node is fine even on a non main thread.
+     * <p>
+     *     This is a use case where a thread is preparing things for later attachment to the scene graph.
+     * </p>
+     */
+    @Test
+    public void testNodeMutationOnNonConnectedNodesOnNonMainThread() throws ExecutionException, InterruptedException {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        Future<Node> nonConnectedNodeFuture = executorService.submit(() -> {
+            // This should work fine since these nodes are not connected to the root node
+            Node parent = new Node("parent");
+            Node child = new Node("child");
+            parent.attachChild(child);
+
+            // Add another level of children
+            Node grandchild = new Node("grandchild");
+            child.attachChild(grandchild);
+
+            return parent;
+        });
+
+        // Get the result to ensure the task completed without exceptions
+        Node nonConnectedNode = nonConnectedNodeFuture.get();
+
+        // Now we can attach it to the root node on the main thread
+        rootNode.attachChild(nonConnectedNode);
+    }
+
+    /**
+     * Test that adding a node to the scene graph connected to the root node in a non main thread leads to an
+     * exception.
+     */
+    @Test
+    public void testAddingNodeToSceneGraphOnNonMainThread() throws InterruptedException {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        // Create a child node and attach it to the root node
+        Node child = new Node("child");
+        rootNode.attachChild(child);
+
+        Future<Void> illegalMutationFuture = executorService.submit(() -> {
+            // This should fail because we're trying to add a node to a node that's connected to the scene graph
+            Node grandchild = new Node("grandchild");
+            child.attachChild(grandchild);
+            return null;
+        });
+
+        try {
+            illegalMutationFuture.get();
+            fail("Expected an IllegalThreadSceneGraphMutation exception");
+        } catch (ExecutionException e) {
+            // This is expected - verify it's the right exception type
+            assertTrue("Expected IllegalThreadSceneGraphMutation, got: " + e.getCause().getClass().getName(),
+                    e.getCause() instanceof IllegalThreadSceneGraphMutation);
+        }
+    }
+
+    /**
+     * Test that adding a node currently attached to a root node to a different node leads to an exception.
+     * <p>
+     *     This is testing an edge case where you think you'd working with non-connected nodes, but in reality
+     *     one of your nodes is already attached to the scene graph (and you're attaching it to a different node which will
+     *     detach it from the scene graph).
+     * </p>
+     */
+    @Test
+    public void testMovingNodeAttachedToRootOnNonMainThread() throws InterruptedException {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        // Create two child nodes and attach them to the root node
+        Node child1 = new Node("child1");
+        Node child2 = new Node("child2");
+
+        rootNode.attachChild(child2);
+
+        Future<Void> illegalMutationFuture = executorService.submit(() -> {
+            // This should fail because we're trying to move a node that's connected to the root node
+            child1.attachChild(child2); // This implicitly detaches child2 from rootNode
+            return null;
+        });
+
+        try {
+            illegalMutationFuture.get();
+            fail("Expected an IllegalThreadSceneGraphMutation exception");
+        } catch (ExecutionException e) {
+            // This is expected - verify it's the right exception type
+            assertTrue("Expected IllegalThreadSceneGraphMutation, got: " + e.getCause().getClass().getName(),
+                    e.getCause() instanceof IllegalThreadSceneGraphMutation);
+        }
+    }
+
+    /**
+     * Test that detaching a node releases it from thread protection.
+     */
+    @Test
+    public void testDetachmentReleasesProtection() throws ExecutionException, InterruptedException {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        // Create a child node and attach it to the root node
+        Node child = new Node("child");
+        rootNode.attachChild(child);
+
+        // Now detach it from the root node
+        child.removeFromParent();
+
+        // Now we should be able to modify it on another thread
+        Future<Void> legalMutationFuture = executorService.submit(() -> {
+            Node grandchild = new Node("grandchild");
+            child.attachChild(grandchild);
+            return null;
+        });
+
+        // This should complete without exceptions
+        legalMutationFuture.get();
+    }
+
+    /**
+     * Test that adding a child to the root node also restricts the grandchild.
+     * This test will add a grandchild to a child BEFORE adding the child to the root,
+     * then try (and fail) to make an illegal on-thread change to the grandchild.
+     */
+    @Test
+    public void testAddingAChildToTheRootNodeAlsoRestrictsTheGrandChild() throws InterruptedException {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        // Create a child node and a grandchild node
+        Node child = new Node("child");
+        Node grandchild = new Node("grandchild");
+
+        // Attach the grandchild to the child BEFORE adding the child to the root
+        child.attachChild(grandchild);
+
+        // Now attach the child to the root node
+        rootNode.attachChild(child);
+
+        // Try to make an illegal on-thread change to the grandchild
+        Future<Void> illegalMutationFuture = executorService.submit(() -> {
+            // This should fail because the grandchild is now restricted
+            Node greatGrandchild = new Node("greatGrandchild");
+            grandchild.attachChild(greatGrandchild);
+            return null;
+        });
+
+        try {
+            illegalMutationFuture.get();
+            fail("Expected an IllegalThreadSceneGraphMutation exception");
+        } catch (ExecutionException e) {
+            // This is expected - verify it's the right exception type
+            assertTrue("Expected IllegalThreadSceneGraphMutation, got: " + e.getCause().getClass().getName(),
+                    e.getCause() instanceof IllegalThreadSceneGraphMutation);
+        }
+    }
+
+    /**
+     * Test that removing a child from the root node also unrestricts the grandchild.
+     * This test will add a child with a grandchild to the root node, then remove the child
+     * and verify that the grandchild can be modified on a non-main thread.
+     */
+    @Test
+    public void testRemovingAChildFromTheRootNodeAlsoUnrestrictsTheGrandChild() throws ExecutionException, InterruptedException {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        // Create a child node and a grandchild node
+        Node child = new Node("child");
+        Node grandchild = new Node("grandchild");
+
+        // Attach the grandchild to the child
+        child.attachChild(grandchild);
+
+        // Attach the child to the root node
+        rootNode.attachChild(child);
+
+        // Now remove the child from the root node
+        child.removeFromParent();
+
+        // Try to make a change to the grandchild on a non-main thread
+        Future<Void> legalMutationFuture = executorService.submit(() -> {
+            // This should succeed because the grandchild is no longer restricted
+            Node greatGrandchild = new Node("greatGrandchild");
+            grandchild.attachChild(greatGrandchild);
+            return null;
+        });
+
+        // This should complete without exceptions
+        legalMutationFuture.get();
+    }
+
+    /**
+     * Test that an otherwise illegal scene graph mutation won't throw an exception
+     * if the checks have been disabled by calling disableChecks().
+     */
+    @Test
+    public void testDisableChecksAllowsIllegalMutation() throws ExecutionException, InterruptedException {
+        Node rootNode = new Node("root");
+        SceneGraphThreadWarden.setup(rootNode);
+
+        // Create a child node and attach it to the root node
+        Node child = new Node("child");
+        rootNode.attachChild(child);
+
+        // Disable the thread warden checks
+        SceneGraphThreadWarden.disableChecks();
+
+        // Try to make a change to the child on a non-main thread
+        // This would normally be illegal, but should succeed because checks are disabled
+        Future<Void> mutationFuture = executorService.submit(() -> {
+            Node grandchild = new Node("grandchild");
+            child.attachChild(grandchild);
+            return null;
+        });
+
+        // This should complete without exceptions
+        mutationFuture.get();
+    }
+
+
+
+    /**
+     * Creates a single-threaded executor service with daemon threads.
+     */
+    private static ExecutorService newSingleThreadDaemonExecutor() {
+        return Executors.newSingleThreadExecutor(daemonThreadFactory());
+    }
+
+    /**
+     * Creates a thread factory that produces daemon threads.
+     */
+    private static ThreadFactory daemonThreadFactory() {
+        return r -> {
+            Thread t = Executors.defaultThreadFactory().newThread(r);
+            t.setDaemon(true);
+            return t;
+        };
+    }
+}

+ 1 - 1
natives-snapshot.properties

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