Procházet zdrojové kódy

Merge branch 'master' into yaRnMcDonuts-patch-1

Ryan McDonough před 8 měsíci
rodič
revize
45ba9fb166
89 změnil soubory, kde provedl 6450 přidání a 1839 odebrání
  1. 43 1
      .github/workflows/main.yml
  2. 139 91
      jme3-android/src/main/java/com/jme3/system/android/OGLESContext.java
  3. 15 11
      jme3-core/src/main/java/com/jme3/anim/AnimComposer.java
  4. 39 12
      jme3-core/src/main/java/com/jme3/app/LegacyApplication.java
  5. 40 12
      jme3-core/src/main/java/com/jme3/system/AppSettings.java
  6. 65 0
      jme3-core/src/main/java/com/jme3/system/DisplayInfo.java
  7. 100 0
      jme3-core/src/main/java/com/jme3/system/Displays.java
  8. 18 3
      jme3-core/src/main/java/com/jme3/system/JmeContext.java
  9. 44 37
      jme3-core/src/main/java/com/jme3/system/NullContext.java
  10. 13 132
      jme3-core/src/main/java/com/jme3/util/TangentBinormalGenerator.java
  11. 148 0
      jme3-core/src/main/java/com/jme3/util/TangentUtils.java
  12. 55 40
      jme3-core/src/main/java/com/jme3/util/mikktspace/MikktspaceTangentGenerator.java
  13. 2 4
      jme3-core/src/main/resources/Common/MatDefs/Light/PBRLighting.frag
  14. 73 0
      jme3-core/src/main/resources/Common/MatDefs/Light/modular/PBRLighting.frag
  15. 321 0
      jme3-core/src/main/resources/Common/MatDefs/Light/modular/PBRLighting.j3md
  16. 75 0
      jme3-core/src/main/resources/Common/MatDefs/Light/modular/PBRLighting.vert
  17. 3 0
      jme3-core/src/main/resources/Common/ShaderLib/GLSLCompat.glsllib
  18. 14 1
      jme3-core/src/main/resources/Common/ShaderLib/Math.glsllib
  19. 34 0
      jme3-core/src/main/resources/Common/ShaderLib/module/Light.glsl
  20. 39 0
      jme3-core/src/main/resources/Common/ShaderLib/module/PBRSurface.glsl
  21. 592 0
      jme3-core/src/main/resources/Common/ShaderLib/module/pbrlighting/PBRLightingUtils.glsllib
  22. 33 10
      jme3-core/src/test/java/com/jme3/anim/AnimComposerTest.java
  23. 48 0
      jme3-core/src/test/java/com/jme3/shader/GLSLPreprocessorTest.java
  24. 8 0
      jme3-core/src/test/resources/GLSLPreprocessorTest.testStruct.validOutput
  25. 17 0
      jme3-core/src/test/resources/GLSLPreprocessorTest.testStructExtends.validOutput
  26. 21 0
      jme3-core/src/test/resources/GLSLPreprocessorTest.testStructExtendsMulti.validOutput
  27. 93 0
      jme3-core/src/tools/java/jme3tools/shader/Preprocessor.java
  28. 181 172
      jme3-desktop/src/main/java/com/jme3/system/AWTContext.java
  29. 33 25
      jme3-desktop/src/main/java/com/jme3/system/awt/AwtPanelsContext.java
  30. 244 0
      jme3-examples/src/main/java/jme3test/app/TestMonitorApp.java
  31. 5 3
      jme3-examples/src/main/java/jme3test/app/TestResizableApp.java
  32. 3 3
      jme3-examples/src/main/java/jme3test/batching/TestBatchNode.java
  33. 2 2
      jme3-examples/src/main/java/jme3test/effect/TestEverything.java
  34. 2 2
      jme3-examples/src/main/java/jme3test/helloworld/HelloMaterial.java
  35. 3 3
      jme3-examples/src/main/java/jme3test/light/TestDirectionalLightShadow.java
  36. 2 2
      jme3-examples/src/main/java/jme3test/light/TestShadowsPerf.java
  37. 2 2
      jme3-examples/src/main/java/jme3test/light/TestSimpleLighting.java
  38. 3 3
      jme3-examples/src/main/java/jme3test/light/TestSpotLight.java
  39. 3 3
      jme3-examples/src/main/java/jme3test/light/TestSpotLightShadows.java
  40. 2 2
      jme3-examples/src/main/java/jme3test/light/TestTangentCube.java
  41. 5 3
      jme3-examples/src/main/java/jme3test/light/TestTangentGen.java
  42. 4 3
      jme3-examples/src/main/java/jme3test/light/TestTangentGenBadUV.java
  43. 2 1
      jme3-examples/src/main/java/jme3test/light/TestTangentSpace.java
  44. 2 2
      jme3-examples/src/main/java/jme3test/light/TestTransparentShadow.java
  45. 2 2
      jme3-examples/src/main/java/jme3test/light/TestTwoSideLighting.java
  46. 2 2
      jme3-examples/src/main/java/jme3test/material/TestBumpModel.java
  47. 2 2
      jme3-examples/src/main/java/jme3test/material/TestNormalMapping.java
  48. 3 3
      jme3-examples/src/main/java/jme3test/material/TestParallax.java
  49. 3 3
      jme3-examples/src/main/java/jme3test/material/TestParallaxPBR.java
  50. 2 2
      jme3-examples/src/main/java/jme3test/material/TestSimpleBumps.java
  51. 2 2
      jme3-examples/src/main/java/jme3test/material/TestUnshadedModel.java
  52. 2 2
      jme3-examples/src/main/java/jme3test/model/anim/TestAnimationFactory.java
  53. 2 2
      jme3-examples/src/main/java/jme3test/post/TestLightScattering.java
  54. 2 2
      jme3-examples/src/main/java/jme3test/post/TestPostFilters.java
  55. 2 2
      jme3-examples/src/main/java/jme3test/post/TestTransparentSSAO.java
  56. 26 18
      jme3-ios/src/main/java/com/jme3/system/ios/IGLESContext.java
  57. 519 505
      jme3-lwjgl/src/main/java/com/jme3/system/lwjgl/LwjglCanvas.java
  58. 325 285
      jme3-lwjgl/src/main/java/com/jme3/system/lwjgl/LwjglDisplay.java
  59. 233 221
      jme3-lwjgl/src/main/java/com/jme3/system/lwjgl/LwjglOffscreenBuffer.java
  60. 4 0
      jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglDisplay.java
  61. 284 144
      jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglWindow.java
  62. 50 0
      jme3-screenshot-tests/README.md
  63. 38 0
      jme3-screenshot-tests/build.gradle
  64. 55 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/App.java
  65. 105 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ExtentReportExtension.java
  66. 66 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/PixelSamenessDegree.java
  67. 331 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotNoInputAppState.java
  68. 120 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTest.java
  69. 56 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTestBase.java
  70. 458 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestDriver.java
  71. 56 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestResolution.java
  72. 53 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestType.java
  73. 290 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/effects/TestExplosionEffect.java
  74. 266 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/effects/TestIssue1773.java
  75. 118 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/export/TestOgreConvert.java
  76. 98 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/gui/TestBitmapText3D.java
  77. 212 0
      jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/water/TestPostWater.java
  78. binární
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestExplosionEffect.testExplosionEffect_f15.png
  79. binární
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestExplosionEffect.testExplosionEffect_f2.png
  80. binární
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestIssue1773.testIssue1773_localSpace_f45.png
  81. binární
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestIssue1773.testIssue1773_worldSpace_f45.png
  82. binární
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.export.TestOgreConvert.testOgreConvert_f1.png
  83. binární
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.export.TestOgreConvert.testOgreConvert_f5.png
  84. binární
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.gui.TestBitmapText3D.testBitmapText3D_f1.png
  85. binární
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.water.TestPostWater.testPostWater_f1.png
  86. 2 2
      jme3-terrain/src/main/java/com/jme3/terrain/geomipmap/TerrainQuad.java
  87. 1 1
      jme3-testdata/src/main/resources/Models/Tank/tank.j3m
  88. 68 54
      jme3-vr/src/main/java/com/jme3/system/lwjgl/LwjglDisplayVR.java
  89. 2 0
      settings.gradle

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

@@ -56,7 +56,49 @@ on:
     types: [published]
 
 jobs:
-
+  ScreenshotTests:
+    name: Run Screenshot Tests
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+    steps:
+    - uses: actions/checkout@v4
+    - name: Set up JDK 17
+      uses: actions/setup-java@v4
+      with:
+        java-version: '17'
+        distribution: 'temurin'
+    - name: Install Mesa3D
+      run: |
+        sudo apt-get update
+        sudo apt-get install -y mesa-utils libgl1-mesa-dri libgl1 libglx-mesa0 xvfb
+    - name: Set environment variables for Mesa3D
+      run: |
+        echo "LIBGL_ALWAYS_SOFTWARE=1" >> $GITHUB_ENV
+        echo "MESA_LOADER_DRIVER_OVERRIDE=llvmpipe" >> $GITHUB_ENV
+    - name: Start xvfb
+      run: |
+        sudo Xvfb :99 -ac -screen 0 1024x768x16 &
+        export DISPLAY=:99
+        echo "DISPLAY=:99" >> $GITHUB_ENV
+    - name: Verify Mesa3D Installation
+      run: |
+        glxinfo | grep "OpenGL"
+    - name: Validate the Gradle wrapper
+      uses: gradle/actions/wrapper-validation@v3
+    - name: Test with Gradle Wrapper
+      run: |
+        ./gradlew :jme3-screenshot-test:screenshotTest
+    - name: Upload Test Reports
+      uses: actions/upload-artifact@master
+      if: always()
+      with:
+        name: screenshot-test-report
+        retention-days: 30
+        path: |
+          **/build/reports/**
+          **/build/changed-images/**
+          **/build/test-results/**
   # Build the natives on android
   BuildAndroidNatives:
     name: Build natives for android

+ 139 - 91
jme3-android/src/main/java/com/jme3/system/android/OGLESContext.java

@@ -59,7 +59,6 @@ import com.jme3.renderer.opengl.*;
 import com.jme3.system.*;
 import com.jme3.util.BufferAllocatorFactory;
 import com.jme3.util.PrimitiveAllocator;
-
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.logging.Level;
 import java.util.logging.Logger;
@@ -79,7 +78,7 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTex
     protected SystemListener listener;
     protected boolean autoFlush = true;
     protected AndroidInputHandler androidInput;
-    protected long minFrameDuration = 0;                   // No FPS cap
+    protected long minFrameDuration = 0; // No FPS cap
     protected long lastUpdateTime = 0;
 
     static {
@@ -90,8 +89,7 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTex
         }
     }
 
-    public OGLESContext() {
-    }
+    public OGLESContext() {}
 
     @Override
     public Type getType() {
@@ -115,9 +113,11 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTex
         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
             // below 4.0, check OpenGL ES 2.0 support.
             if (info.reqGlEsVersion < 0x20000) {
-                throw new UnsupportedOperationException("OpenGL ES 2.0 or better is not supported on this device");
+                throw new UnsupportedOperationException(
+                    "OpenGL ES 2.0 or better is not supported on this device"
+                );
             }
-        } else if (Build.VERSION.SDK_INT < 9){
+        } else if (Build.VERSION.SDK_INT < 9) {
             throw new UnsupportedOperationException("jME3 requires Android 2.3 or later");
         }
 
@@ -127,7 +127,7 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTex
         if (androidInput == null) {
             if (Build.VERSION.SDK_INT >= 14) {
                 androidInput = new AndroidInputHandler14();
-            } else if (Build.VERSION.SDK_INT >= 9){
+            } else if (Build.VERSION.SDK_INT >= 9) {
                 androidInput = new AndroidInputHandler();
             }
         }
@@ -137,7 +137,7 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTex
         // setEGLContextClientVersion must be set before calling setRenderer
         // this means it cannot be set in AndroidConfigChooser (too late)
         // use proper openGL ES version
-        view.setEGLContextClientVersion(info.reqGlEsVersion>>16);
+        view.setEGLContextClientVersion(info.reqGlEsVersion >> 16);
 
         view.setFocusableInTouchMode(true);
         view.setFocusable(true);
@@ -200,22 +200,35 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTex
         logger.log(Level.FINE, "Running on thread: {0}", Thread.currentThread().getName());
 
         // Setup unhandled Exception Handler
-        Thread.currentThread().setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
-            @Override
-            public void uncaughtException(Thread thread, Throwable thrown) {
-                listener.handleError("Exception thrown in " + thread.toString(), thrown);
-            }
-        });
+        Thread
+            .currentThread()
+            .setUncaughtExceptionHandler(
+                new Thread.UncaughtExceptionHandler() {
+                    @Override
+                    public void uncaughtException(Thread thread, Throwable thrown) {
+                        listener.handleError("Exception thrown in " + thread.toString(), thrown);
+                    }
+                }
+            );
 
         timer = new NanoTimer();
         GL gl = new AndroidGL();
         if (settings.getBoolean("GraphicsDebug")) {
-            gl = (GL) GLDebug.createProxy(gl, gl, GL.class, GL2.class, GLES_30.class, GLFbo.class, GLExt.class);
+            gl =
+                (GL) GLDebug.createProxy(
+                    gl,
+                    gl,
+                    GL.class,
+                    GL2.class,
+                    GLES_30.class,
+                    GLFbo.class,
+                    GLExt.class
+                );
         }
         if (settings.getBoolean("GraphicsTrace")) {
-            gl = (GL)GLTracer.createGlesTracer(gl, GL.class, GLES_30.class, GLFbo.class, GLExt.class);
+            gl = (GL) GLTracer.createGlesTracer(gl, GL.class, GLES_30.class, GLFbo.class, GLExt.class);
         }
-        renderer = new GLRenderer(gl, (GLExt)gl, (GLFbo)gl);
+        renderer = new GLRenderer(gl, (GLExt) gl, (GLFbo) gl);
         renderer.initialize();
 
         JmeSystem.setSoftTextDialogInput(this);
@@ -254,7 +267,7 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTex
         }
 
         if (settings.getFrameRate() > 0) {
-            minFrameDuration = (long)(1000d / settings.getFrameRate()); // ms
+            minFrameDuration = (long) (1000d / settings.getFrameRate()); // ms
             logger.log(Level.FINE, "Setting min tpf: {0}ms", minFrameDuration);
         } else {
             minFrameDuration = 0;
@@ -312,8 +325,7 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTex
     }
 
     @Override
-    public void setTitle(String title) {
-    }
+    public void setTitle(String title) {}
 
     @Override
     public boolean isCreated() {
@@ -329,7 +341,11 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTex
     @Override
     public void onSurfaceChanged(GL10 gl, int width, int height) {
         if (logger.isLoggable(Level.FINE)) {
-            logger.log(Level.FINE, "GL Surface changed, width: {0} height: {1}", new Object[]{width, height});
+            logger.log(
+                Level.FINE,
+                "GL Surface changed, width: {0} height: {1}",
+                new Object[] { width, height }
+            );
         }
         // update the application settings with the new resolution
         settings.setResolution(width, height);
@@ -372,16 +388,14 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTex
 
             // Enforce a FPS cap
             if (updateDelta < minFrameDuration) {
-//                    logger.log(Level.INFO, "lastUpdateTime: {0}, updateDelta: {1}, minTimePerFrame: {2}",
-//                            new Object[]{lastUpdateTime, updateDelta, minTimePerFrame});
+                //                    logger.log(Level.INFO, "lastUpdateTime: {0}, updateDelta: {1}, minTimePerFrame: {2}",
+                //                            new Object[]{lastUpdateTime, updateDelta, minTimePerFrame});
                 try {
                     Thread.sleep(minFrameDuration - updateDelta);
-                } catch (InterruptedException e) {
-                }
+                } catch (InterruptedException e) {}
             }
 
             lastUpdateTime = System.currentTimeMillis();
-
         }
     }
 
@@ -402,8 +416,7 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTex
     }
 
     @Override
-    public void restart() {
-    }
+    public void restart() {}
 
     @Override
     public void destroy(boolean waitFor) {
@@ -421,76 +434,99 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTex
         while (renderable.get() != createdVal) {
             try {
                 Thread.sleep(10);
-            } catch (InterruptedException ex) {
-            }
+            } catch (InterruptedException ex) {}
         }
     }
 
     @Override
-    public void requestDialog(final int id, final String title, final String initialValue, final SoftTextDialogInputListener listener) {
+    public void requestDialog(
+        final int id,
+        final String title,
+        final String initialValue,
+        final SoftTextDialogInputListener listener
+    ) {
         if (logger.isLoggable(Level.FINE)) {
-            logger.log(Level.FINE, "requestDialog: title: {0}, initialValue: {1}",
-                    new Object[]{title, initialValue});
+            logger.log(
+                Level.FINE,
+                "requestDialog: title: {0}, initialValue: {1}",
+                new Object[] { title, initialValue }
+            );
         }
 
         final View view = JmeAndroidSystem.getView();
-        view.getHandler().post(new Runnable() {
-            @Override
-            public void run() {
-
-                final FrameLayout layoutTextDialogInput = new FrameLayout(view.getContext());
-                final EditText editTextDialogInput = new EditText(view.getContext());
-                editTextDialogInput.setWidth(LayoutParams.FILL_PARENT);
-                editTextDialogInput.setHeight(LayoutParams.FILL_PARENT);
-                editTextDialogInput.setPadding(20, 20, 20, 20);
-                editTextDialogInput.setGravity(Gravity.FILL_HORIZONTAL);
-                //editTextDialogInput.setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI);
-
-                editTextDialogInput.setText(initialValue);
-
-                switch (id) {
-                    case SoftTextDialogInput.TEXT_ENTRY_DIALOG:
-
-                        editTextDialogInput.setInputType(InputType.TYPE_CLASS_TEXT);
-                        break;
-
-                    case SoftTextDialogInput.NUMERIC_ENTRY_DIALOG:
-
-                        editTextDialogInput.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL | InputType.TYPE_NUMBER_FLAG_SIGNED);
-                        break;
-
-                    case SoftTextDialogInput.NUMERIC_KEYPAD_DIALOG:
-
-                        editTextDialogInput.setInputType(InputType.TYPE_CLASS_PHONE);
-                        break;
-
-                    default:
-                        break;
+        view
+            .getHandler()
+            .post(
+                new Runnable() {
+                    @Override
+                    public void run() {
+                        final FrameLayout layoutTextDialogInput = new FrameLayout(view.getContext());
+                        final EditText editTextDialogInput = new EditText(view.getContext());
+                        editTextDialogInput.setWidth(LayoutParams.FILL_PARENT);
+                        editTextDialogInput.setHeight(LayoutParams.FILL_PARENT);
+                        editTextDialogInput.setPadding(20, 20, 20, 20);
+                        editTextDialogInput.setGravity(Gravity.FILL_HORIZONTAL);
+                        //editTextDialogInput.setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI);
+
+                        editTextDialogInput.setText(initialValue);
+
+                        switch (id) {
+                            case SoftTextDialogInput.TEXT_ENTRY_DIALOG:
+                                editTextDialogInput.setInputType(InputType.TYPE_CLASS_TEXT);
+                                break;
+                            case SoftTextDialogInput.NUMERIC_ENTRY_DIALOG:
+                                editTextDialogInput.setInputType(
+                                    InputType.TYPE_CLASS_NUMBER |
+                                    InputType.TYPE_NUMBER_FLAG_DECIMAL |
+                                    InputType.TYPE_NUMBER_FLAG_SIGNED
+                                );
+                                break;
+                            case SoftTextDialogInput.NUMERIC_KEYPAD_DIALOG:
+                                editTextDialogInput.setInputType(InputType.TYPE_CLASS_PHONE);
+                                break;
+                            default:
+                                break;
+                        }
+
+                        layoutTextDialogInput.addView(editTextDialogInput);
+
+                        AlertDialog dialogTextInput = new AlertDialog.Builder(view.getContext())
+                            .setTitle(title)
+                            .setView(layoutTextDialogInput)
+                            .setPositiveButton(
+                                "OK",
+                                new DialogInterface.OnClickListener() {
+                                    @Override
+                                    public void onClick(DialogInterface dialog, int whichButton) {
+                                        /* User clicked OK, send COMPLETE action
+                                         * and text */
+                                        listener.onSoftText(
+                                            SoftTextDialogInputListener.COMPLETE,
+                                            editTextDialogInput.getText().toString()
+                                        );
+                                    }
+                                }
+                            )
+                            .setNegativeButton(
+                                "Cancel",
+                                new DialogInterface.OnClickListener() {
+                                    @Override
+                                    public void onClick(DialogInterface dialog, int whichButton) {
+                                        /* User clicked CANCEL, send CANCEL action
+                                         * and text */
+                                        listener.onSoftText(
+                                            SoftTextDialogInputListener.CANCEL,
+                                            editTextDialogInput.getText().toString()
+                                        );
+                                    }
+                                }
+                            )
+                            .create();
+
+                        dialogTextInput.show();
+                    }
                 }
-
-                layoutTextDialogInput.addView(editTextDialogInput);
-
-                AlertDialog dialogTextInput = new AlertDialog.Builder(view.getContext()).setTitle(title).setView(layoutTextDialogInput).setPositiveButton("OK",
-                        new DialogInterface.OnClickListener() {
-                            @Override
-                            public void onClick(DialogInterface dialog, int whichButton) {
-                                /* User clicked OK, send COMPLETE action
-                                 * and text */
-                                listener.onSoftText(SoftTextDialogInputListener.COMPLETE, editTextDialogInput.getText().toString());
-                            }
-                        }).setNegativeButton("Cancel",
-                        new DialogInterface.OnClickListener() {
-                            @Override
-                            public void onClick(DialogInterface dialog, int whichButton) {
-                                /* User clicked CANCEL, send CANCEL action
-                                 * and text */
-                                listener.onSoftText(SoftTextDialogInputListener.CANCEL, editTextDialogInput.getText().toString());
-                            }
-                        }).create();
-
-                dialogTextInput.show();
-            }
-        });
+            );
     }
 
     @Override
@@ -542,11 +578,11 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTex
     public int getWindowYPosition() {
         throw new UnsupportedOperationException("not implemented yet");
     }
-    
+
     /**
      * Retrieves the dimensions of the input surface. Note: do not modify the
      * returned object.
-     * 
+     *
      * @return the dimensions (in pixels, left and top are 0)
      */
     private Rect getSurfaceFrame() {
@@ -555,4 +591,16 @@ public class OGLESContext implements JmeContext, GLSurfaceView.Renderer, SoftTex
         Rect result = holder.getSurfaceFrame();
         return result;
     }
+
+    @Override
+    public Displays getDisplays() {
+        // TODO Auto-generated method stub
+        return null;
+    }
+
+    @Override
+    public int getPrimaryDisplay() {
+        // TODO Auto-generated method stub
+        return 0;
+    }
 }

+ 15 - 11
jme3-core/src/main/java/com/jme3/anim/AnimComposer.java

@@ -52,6 +52,7 @@ import java.util.*;
  * @author Nehon
  */
 public class AnimComposer extends AbstractControl {
+
     /**
      * The name of the default layer.
      */
@@ -121,7 +122,7 @@ public class AnimComposer extends AbstractControl {
      * @return The action corresponding to the given name.
      */
     public Action setCurrentAction(String name) {
-        return setCurrentAction(name, DEFAULT_LAYER);
+        return setCurrentAction(name, DEFAULT_LAYER, true);
     }
 
     /**
@@ -144,9 +145,9 @@ public class AnimComposer extends AbstractControl {
      * @return The action corresponding to the given name.
      */
     public Action setCurrentAction(String actionName, String layerName, boolean loop) {
-        AnimLayer l = getLayer(layerName);
+        AnimLayer layer = getLayer(layerName);
         Action currentAction = action(actionName);
-        l.setCurrentAction(actionName, currentAction, loop);
+        layer.setCurrentAction(actionName, currentAction, loop);
 
         return currentAction;
     }
@@ -239,7 +240,8 @@ public class AnimComposer extends AbstractControl {
     /**
      *
      * @param name The name of the action to return.
-     * @return The action registered with specified name. It will make a new action if there isn't any.
+     * @return The action registered with specified name. It will make a new
+     * action if there isn't any.
      * @see #makeAction(java.lang.String)
      */
     public Action action(String name) {
@@ -254,7 +256,8 @@ public class AnimComposer extends AbstractControl {
     /**
      *
      * @param name The name of the action to return.
-     * @return The action registered with specified name or null if nothing is registered.
+     * @return The action registered with specified name or null if nothing is
+     * registered.
      */
     public Action getAction(String name) {
         return actions.get(name);
@@ -331,8 +334,8 @@ public class AnimComposer extends AbstractControl {
     }
 
     /**
-     * Creates an action that will interpolate over an entire sequence
-     * of tweens in order.
+     * Creates an action that will interpolate over an entire sequence of tweens
+     * in order.
      *
      * @param name a name for the new Action
      * @param tweens the desired sequence of tweens
@@ -374,8 +377,9 @@ public class AnimComposer extends AbstractControl {
     }
 
     /**
-     * Returns an unmodifiable collection of all available animations. When an attempt
-     * is made to modify the collection, an UnsupportedOperationException is thrown.
+     * Returns an unmodifiable collection of all available animations. When an
+     * attempt is made to modify the collection, an
+     * UnsupportedOperationException is thrown.
      *
      * @return the unmodifiable collection of animations
      */
@@ -526,9 +530,8 @@ public class AnimComposer extends AbstractControl {
         for (String key : layers.keySet()) {
             newLayers.put(key, cloner.clone(layers.get(key)));
         }
-
+        newLayers.putIfAbsent(DEFAULT_LAYER, new AnimLayer(DEFAULT_LAYER, null));
         layers = newLayers;
-
     }
 
     /**
@@ -546,6 +549,7 @@ public class AnimComposer extends AbstractControl {
         animClipMap = (Map<String, AnimClip>) ic.readStringSavableMap("animClipMap", new HashMap<String, AnimClip>());
         globalSpeed = ic.readFloat("globalSpeed", 1f);
         layers = (Map<String, AnimLayer>) ic.readStringSavableMap("layers", new HashMap<String, AnimLayer>());
+        layers.putIfAbsent(DEFAULT_LAYER, new AnimLayer(DEFAULT_LAYER, null));
     }
 
     /**

+ 39 - 12
jme3-core/src/main/java/com/jme3/app/LegacyApplication.java

@@ -50,13 +50,14 @@ import com.jme3.renderer.RenderManager;
 import com.jme3.renderer.Renderer;
 import com.jme3.renderer.ViewPort;
 import com.jme3.system.AppSettings;
+import com.jme3.system.Displays;
 import com.jme3.system.JmeContext;
 import com.jme3.system.JmeContext.Type;
-import com.jme3.util.res.Resources;
 import com.jme3.system.JmeSystem;
 import com.jme3.system.NanoTimer;
 import com.jme3.system.SystemListener;
 import com.jme3.system.Timer;
+import com.jme3.util.res.Resources;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.util.concurrent.Callable;
@@ -199,8 +200,7 @@ public class LegacyApplication implements Application, SystemListener {
     @Deprecated
     public void setAssetManager(AssetManager assetManager) {
         if (this.assetManager != null) {
-            throw new IllegalStateException("Can only set asset manager"
-                    + " before initialization.");
+            throw new IllegalStateException("Can only set asset manager" + " before initialization.");
         }
 
         this.assetManager = assetManager;
@@ -220,13 +220,16 @@ public class LegacyApplication implements Application, SystemListener {
                 if (assetCfgUrl == null) {
                     assetCfgUrl = Resources.getResource(assetCfg);
                     if (assetCfgUrl == null) {
-                        logger.log(Level.SEVERE, "Unable to access AssetConfigURL in asset config:{0}", 
-                                assetCfg);
+                        logger.log(
+                            Level.SEVERE,
+                            "Unable to access AssetConfigURL in asset config:{0}",
+                            assetCfg
+                        );
                         return;
                     }
                 }
             }
-        }      
+        }
         if (assetCfgUrl == null) {
             assetCfgUrl = JmeSystem.getPlatformAssetConfigURL();
         }
@@ -595,7 +598,6 @@ public class LegacyApplication implements Application, SystemListener {
         }
     }
 
-
     @Override
     public void rescale(float x, float y) {
         if (renderManager != null) {
@@ -668,9 +670,8 @@ public class LegacyApplication implements Application, SystemListener {
         initAudio();
 
         // update timer so that the next delta is not too large
-//        timer.update();
+        //        timer.update();
         timer.reset();
-
         // user code here
     }
 
@@ -684,8 +685,12 @@ public class LegacyApplication implements Application, SystemListener {
         // Display error message on screen if not in headless mode
         if (context.getType() != JmeContext.Type.Headless) {
             if (t != null) {
-                JmeSystem.handleErrorMessage(errMsg + "\n" + t.getClass().getSimpleName()
-                        + (t.getMessage() != null ? ": " + t.getMessage() : ""));
+                JmeSystem.handleErrorMessage(
+                    errMsg +
+                    "\n" +
+                    t.getClass().getSimpleName() +
+                    (t.getMessage() != null ? ": " + t.getMessage() : "")
+                );
             } else {
                 JmeSystem.handleErrorMessage(errMsg);
             }
@@ -811,7 +816,6 @@ public class LegacyApplication implements Application, SystemListener {
             }
             audioRenderer.update(timer.getTimePerFrame());
         }
-
         // user code here
     }
 
@@ -866,6 +870,7 @@ public class LegacyApplication implements Application, SystemListener {
     }
 
     private class RunnableWrapper implements Callable {
+
         private final Runnable runnable;
 
         public RunnableWrapper(Runnable runnable) {
@@ -878,4 +883,26 @@ public class LegacyApplication implements Application, SystemListener {
             return null;
         }
     }
+
+    /**
+     * This call will return a list of Monitors that glfwGetMonitors()
+     * returns and information about the monitor, like width, height,
+     * and refresh rate.
+     *
+     * @return returns a list of monitors and their information.
+     */
+    public Displays getDisplays() {
+        return context.getDisplays();
+    }
+
+    /**
+     * Use this to get the positional number of the primary
+     * monitor from the glfwGetMonitors() function call.
+     *
+     * @return the position of the value in the arraylist of
+     *         the primary monitor.
+     */
+    public int getPrimaryDisplay() {
+        return context.getPrimaryDisplay();
+    }
 }

+ 40 - 12
jme3-core/src/main/java/com/jme3/system/AppSettings.java

@@ -86,7 +86,6 @@ public final class AppSettings extends HashMap<String, Object> {
     @Deprecated
     public static final String LWJGL_OPENGL3 = "LWJGL-OpenGL3";
 
-
     /**
      * Use LWJGL as the display system and force using the core OpenGL3.0 renderer.
      * <p>
@@ -266,6 +265,7 @@ public final class AppSettings extends HashMap<String, Object> {
     public static final String JOAL = "JOAL";
 
     static {
+        defaults.put("Display", 0);
         defaults.put("CenterWindow", true);
         defaults.put("Width", 640);
         defaults.put("Height", 480);
@@ -436,7 +436,9 @@ public final class AppSettings extends HashMap<String, Object> {
                             put(key.substring(2), prefs.getBoolean(key, false));
                             break;
                         default:
-                            throw new UnsupportedOperationException("Undefined setting type: " + key.charAt(0));
+                            throw new UnsupportedOperationException(
+                                "Undefined setting type: " + key.charAt(0)
+                            );
                     }
                 } else {
                     // Use old method for compatibility with older preferences
@@ -718,7 +720,7 @@ public final class AppSettings extends HashMap<String, Object> {
      * @param clazz The custom context class.
      * (Default: not set)
      */
-    public void setCustomRenderer(Class<? extends JmeContext> clazz){
+    public void setCustomRenderer(Class<? extends JmeContext> clazz) {
         put("Renderer", "CUSTOM" + clazz.getName());
     }
 
@@ -766,7 +768,7 @@ public final class AppSettings extends HashMap<String, Object> {
 
     /**
      * Set the size of the window
-     * 
+     *
      * @param width The width in pixels (default = width of the default framebuffer)
      * @param height The height in pixels (default = height of the default framebuffer)
      */
@@ -802,8 +804,6 @@ public final class AppSettings extends HashMap<String, Object> {
         setMinHeight(height);
     }
 
-
-
     /**
      * Set the frequency, also known as refresh rate, for the
      * rendering display.
@@ -825,7 +825,7 @@ public final class AppSettings extends HashMap<String, Object> {
      *
      * @param value The depth bits
      */
-    public void setDepthBits(int value){
+    public void setDepthBits(int value) {
         putInteger("DepthBits", value);
     }
 
@@ -844,7 +844,7 @@ public final class AppSettings extends HashMap<String, Object> {
      *
      * @param value The alpha bits
      */
-    public void setAlphaBits(int value){
+    public void setAlphaBits(int value) {
         putInteger("AlphaBits", value);
     }
 
@@ -859,7 +859,7 @@ public final class AppSettings extends HashMap<String, Object> {
      *
      * @param value Number of stencil bits
      */
-    public void setStencilBits(int value){
+    public void setStencilBits(int value) {
         putInteger("StencilBits", value);
     }
 
@@ -921,7 +921,7 @@ public final class AppSettings extends HashMap<String, Object> {
      *
      * @param value true to enable 3-D stereo, false to disable (default=false)
      */
-    public void setStereo3D(boolean value){
+    public void setStereo3D(boolean value) {
         putBoolean("Stereo3D", value);
     }
 
@@ -1180,7 +1180,7 @@ public final class AppSettings extends HashMap<String, Object> {
      * @return true if 3-D stereo is enabled, otherwise false
      * @see #setStereo3D(boolean)
      */
-    public boolean useStereo3D(){
+    public boolean useStereo3D() {
         return getBoolean("Stereo3D");
     }
 
@@ -1301,7 +1301,7 @@ public final class AppSettings extends HashMap<String, Object> {
      * Without this, many openGL calls might fail without notice, so turning it on is recommended for development.
      * Graphics Debug mode will also label native objects and group calls on supported renderers. Compatible
      * graphics debuggers will be able to use this data to show a better outlook of your application
-     * 
+     *
      * @return whether the context will be run in Graphics Debug Mode or not
      * @see #setGraphicsDebug(boolean)
      */
@@ -1479,4 +1479,32 @@ public final class AppSettings extends HashMap<String, Object> {
     public void setWindowYPosition(int pos) {
         putInteger("WindowYPosition", pos);
     }
+
+    /**
+     * Gets the display number used when creating a window.
+     *
+     * <p>
+     * This setting is used only with LWJGL3, it defines which display to use when creating a OpenGL
+     * window.
+     *
+     * @return the desired display used when creating a OpenGL window
+     */
+    public int getDisplay() {
+        return getInteger("Display");
+    }
+
+    /**
+     * Sets the display number used when creating a window. The position number is the number in the
+     * list of monitors GlfwGetMonitors returns.
+     *
+     * <p>
+     * This setting is used only with LWJGL3, it defines which display to use when creating a OpenGL
+     * window. its default value is 0.
+     *
+     * @param mon the desired display used when creating a OpenGL window
+     *
+     */
+    public void setDisplay(int mon) {
+        putInteger("Display", mon);
+    }
 }

+ 65 - 0
jme3-core/src/main/java/com/jme3/system/DisplayInfo.java

@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2009-2023 jMonkeyEngine All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification, are permitted
+ * provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice, this list of conditions
+ * and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright notice, this list of
+ * conditions and the following disclaimer in the documentation and/or other materials provided with
+ * the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors may be used to endorse or
+ * promote products derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
+ * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
+ * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+ * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
+ * WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.jme3.system;
+
+/**
+ * This class holds information about the display that was returned by glfwGetMonitors() calls in
+ * the context class
+ *
+ * @author Kevin Bales
+ */
+public class DisplayInfo {
+
+    /**
+     * displayID - display id that was return from Lwjgl3.
+     */
+    public long displayID = 0;
+
+    /**
+     * width - width that was return from Lwjgl3.
+     */
+    public int width = 1080;
+
+    /**
+     * height - height that was return from Lwjgl3.
+     */
+    public int height = 1920;
+
+    /**
+     * rate - refresh rate that was return from Lwjgl3.
+     */
+    public int rate = 60;
+
+    /**
+     * primary - indicates if the display is the primary monitor.
+     */
+    public boolean primary = false;
+
+    /**
+     * name - display name that was return from Lwjgl3.
+     */
+    public String name = "Generic Monitor";
+}

+ 100 - 0
jme3-core/src/main/java/com/jme3/system/Displays.java

@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2009-2023 jMonkeyEngine All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification, are permitted
+ * provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice, this list of conditions
+ * and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright notice, this list of
+ * conditions and the following disclaimer in the documentation and/or other materials provided with
+ * the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors may be used to endorse or
+ * promote products derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
+ * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
+ * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+ * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
+ * WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.jme3.system;
+
+import java.util.ArrayList;
+
+/**
+ * This class holds all information about all displays that where return from the glfwGetMonitors()
+ * call. It stores them into an ArrayList
+ *
+ * @author Kevin Bales
+ */
+public class Displays {
+
+    private ArrayList<DisplayInfo> displays = new ArrayList<DisplayInfo>();
+
+    public int addNewMonitor(long displaysID) {
+        DisplayInfo info = new DisplayInfo();
+        info.displayID = displaysID;
+        displays.add(info);
+        return displays.size() - 1;
+    }
+
+    /**
+     * This function returns the size of the display ArrayList
+     *
+     * @return the
+     */
+    public int size() {
+        return displays.size();
+    }
+
+    /**
+     * Call to get display information on a certain display.
+     *
+     * @param pos the position in the ArrayList of the display information that you want to get.
+     * @return returns the DisplayInfo data for the display called for.
+     */
+    public DisplayInfo get(int pos) {
+        if (pos < displays.size()) return displays.get(pos);
+
+        return null;
+    }
+
+    /**
+     * Set information about this display stored in displayPos display in the array list.
+     *
+     * @param displayPos ArrayList position of display to update
+     * @param name name of the display
+     * @param width the current width the display is displaying
+     * @param height the current height the display is displaying
+     * @param rate the current refresh rate the display is set to
+     */
+    public void setInfo(int displayPos, String name, int width, int height, int rate) {
+        if (displayPos < displays.size()) {
+            DisplayInfo info = displays.get(displayPos);
+            if (info != null) {
+                info.width = width;
+                info.height = height;
+                info.rate = rate;
+                info.name = name;
+            }
+        }
+    }
+
+    /**
+     * This function will mark a certain display as the primary display.
+     *
+     * @param displayPos the position in the ArrayList of which display is the primary display
+     */
+    public void setPrimaryDisplay(int displayPos) {
+        if (displayPos < displays.size()) {
+            DisplayInfo info = displays.get(displayPos);
+            if (info != null) info.primary = true;
+        }
+    }
+}

+ 18 - 3
jme3-core/src/main/java/com/jme3/system/JmeContext.java

@@ -41,7 +41,6 @@ import com.jme3.renderer.Renderer;
  * Represents a rendering context within the engine.
  */
 public interface JmeContext {
-
     /**
      * The type of context.
      */
@@ -77,7 +76,7 @@ public interface JmeContext {
          * any drawable surface. The implementation does not provide any
          * display, input, or sound support.
          */
-        Headless;
+        Headless,
     }
 
     /**
@@ -102,7 +101,7 @@ public interface JmeContext {
     /**
      * Sets the listener that will receive events relating to context
      * creation, update, and destroy.
-     * 
+     *
      * @param listener the desired listener
      */
     public void setSystemListener(SystemListener listener);
@@ -225,4 +224,20 @@ public interface JmeContext {
      * @throws IllegalStateException for a headless or null context
      */
     public int getWindowYPosition();
+
+    /**
+     * This call will return a list of Monitors that glfwGetMonitors() returns and information about
+     * the monitor, like width, height, and refresh rate.
+     *
+     * @return returns a list of monitors and their information.
+     */
+    public Displays getDisplays();
+
+    /**
+     * Use this to get the positional number of the primary monitor from the glfwGetMonitors()
+     * function call.
+     *
+     * @return the position of the value in the arraylist of the primary monitor.
+     */
+    public int getPrimaryDisplay();
 }

+ 44 - 37
jme3-core/src/main/java/com/jme3/system/NullContext.java

@@ -48,7 +48,7 @@ public class NullContext implements JmeContext, Runnable {
     protected static final Logger logger = Logger.getLogger(NullContext.class.getName());
 
     protected static final String THREAD_NAME = "jME3 Headless Main";
-    
+
     protected AtomicBoolean created = new AtomicBoolean(false);
     protected AtomicBoolean needClose = new AtomicBoolean(false);
     protected final Object createdLock = new Object();
@@ -75,26 +75,28 @@ public class NullContext implements JmeContext, Runnable {
     }
 
     @Override
-    public void setSystemListener(SystemListener listener){
+    public void setSystemListener(SystemListener listener) {
         this.listener = listener;
     }
 
-    protected void initInThread(){
+    protected void initInThread() {
         logger.fine("NullContext created.");
         if (logger.isLoggable(Level.FINE)) {
             logger.log(Level.FINE, "Running on thread: {0}", Thread.currentThread().getName());
         }
 
-        Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
-            @Override
-            public void uncaughtException(Thread thread, Throwable thrown) {
-                listener.handleError("Uncaught exception thrown in "+thread.toString(), thrown);
+        Thread.setDefaultUncaughtExceptionHandler(
+            new Thread.UncaughtExceptionHandler() {
+                @Override
+                public void uncaughtException(Thread thread, Throwable thrown) {
+                    listener.handleError("Uncaught exception thrown in " + thread.toString(), thrown);
+                }
             }
-        });
+        );
 
         timer = new NanoTimer();
         renderer = new NullRenderer();
-        synchronized (createdLock){
+        synchronized (createdLock) {
             created.set(true);
             createdLock.notifyAll();
         }
@@ -102,10 +104,10 @@ public class NullContext implements JmeContext, Runnable {
         listener.initialize();
     }
 
-    protected void deinitInThread(){
+    protected void deinitInThread() {
         listener.destroy();
         timer = null;
-        synchronized (createdLock){
+        synchronized (createdLock) {
             created.set(false);
             createdLock.notifyAll();
         }
@@ -142,7 +144,7 @@ public class NullContext implements JmeContext, Runnable {
     }
 
     @Override
-    public void run(){
+    public void run() {
         initInThread();
 
         do {
@@ -159,31 +161,27 @@ public class NullContext implements JmeContext, Runnable {
     }
 
     @Override
-    public void destroy(boolean waitFor){
+    public void destroy(boolean waitFor) {
         needClose.set(true);
-        if (waitFor)
-            waitFor(false);
+        if (waitFor) waitFor(false);
     }
 
     @Override
-    public void create(boolean waitFor){
-        if (created.get()){
+    public void create(boolean waitFor) {
+        if (created.get()) {
             logger.warning("create() called when NullContext is already created!");
             return;
         }
 
         new Thread(this, THREAD_NAME).start();
-        if (waitFor)
-            waitFor(true);
+        if (waitFor) waitFor(true);
     }
 
     @Override
-    public void restart() {
-    }
+    public void restart() {}
 
     @Override
-    public void setAutoFlushFrames(boolean enabled){
-    }
+    public void setAutoFlushFrames(boolean enabled) {}
 
     @Override
     public MouseInput getMouseInput() {
@@ -206,30 +204,28 @@ public class NullContext implements JmeContext, Runnable {
     }
 
     @Override
-    public void setTitle(String title) {
-    }
+    public void setTitle(String title) {}
 
-    public void create(){
+    public void create() {
         create(false);
     }
 
-    public void destroy(){
+    public void destroy() {
         destroy(false);
     }
 
-    protected void waitFor(boolean createdVal){
-        synchronized (createdLock){
-            while (created.get() != createdVal){
+    protected void waitFor(boolean createdVal) {
+        synchronized (createdLock) {
+            while (created.get() != createdVal) {
                 try {
                     createdLock.wait();
-                } catch (InterruptedException ex) {
-                }
+                } catch (InterruptedException ex) {}
             }
         }
     }
 
     @Override
-    public boolean isCreated(){
+    public boolean isCreated() {
         return created.get();
     }
 
@@ -237,12 +233,11 @@ public class NullContext implements JmeContext, Runnable {
     public void setSettings(AppSettings settings) {
         this.settings.copyFrom(settings);
         frameRate = settings.getFrameRate();
-        if (frameRate <= 0)
-            frameRate = 60; // use default update rate.
+        if (frameRate <= 0) frameRate = 60; // use default update rate.
     }
 
     @Override
-    public AppSettings getSettings(){
+    public AppSettings getSettings() {
         return settings;
     }
 
@@ -259,7 +254,7 @@ public class NullContext implements JmeContext, Runnable {
     @Override
     public boolean isRenderable() {
         return true; // Doesn't really matter if true or false. Either way
-                     // RenderManager won't render anything.
+        // RenderManager won't render anything.
     }
 
     @Override
@@ -306,4 +301,16 @@ public class NullContext implements JmeContext, Runnable {
     public int getWindowYPosition() {
         throw new UnsupportedOperationException("null context");
     }
+
+    @Override
+    public Displays getDisplays() {
+        // TODO Auto-generated method stub
+        return null;
+    }
+
+    @Override
+    public int getPrimaryDisplay() {
+        // TODO Auto-generated method stub
+        return 0;
+    }
 }

+ 13 - 132
jme3-core/src/main/java/com/jme3/util/TangentBinormalGenerator.java

@@ -59,9 +59,11 @@ import java.util.logging.Level;
 import java.util.logging.Logger;
 
 /**
- *
+ * @deprecated This is an outdated and non-standard method. Please use @{link MikktspaceTangentGenerator}
+ *             instead.
  * @author Lex (Aleksey Nikiforov)
  */
+@Deprecated
 public class TangentBinormalGenerator {
 
     private static final Logger log = Logger.getLogger(TangentBinormalGenerator.class.getName());
@@ -860,142 +862,21 @@ public class TangentBinormalGenerator {
         }
     }
 
+    /**
+     * @deprecated Use {@link TangentUtils#genTbnLines(com.jme3.scene.Mesh, float) } instead.
+     */
+    @Deprecated
     public static Mesh genTbnLines(Mesh mesh, float scale) {
-        if (mesh.getBuffer(Type.Tangent) == null) {
-            return genNormalLines(mesh, scale);
-        } else {
-            return genTangentLines(mesh, scale);
-        }
+        return TangentUtils.genTbnLines(mesh, scale);
     }
 
+    /**
+     * @deprecated Use {@link TangentUtils#genNormalLines(com.jme3.scene.Mesh, float) } instead.
+     */
+    @Deprecated
     public static Mesh genNormalLines(Mesh mesh, float scale) {
-        FloatBuffer vertexBuffer = (FloatBuffer) mesh.getBuffer(Type.Position).getData();
-        FloatBuffer normalBuffer = (FloatBuffer) mesh.getBuffer(Type.Normal).getData();
-
-        ColorRGBA originColor = ColorRGBA.White;
-        ColorRGBA normalColor = ColorRGBA.Blue;
-
-        Mesh lineMesh = new Mesh();
-        lineMesh.setMode(Mesh.Mode.Lines);
-
-        Vector3f origin = new Vector3f();
-        Vector3f point = new Vector3f();
-
-        FloatBuffer lineVertex = BufferUtils.createFloatBuffer(vertexBuffer.limit() * 2);
-        FloatBuffer lineColor = BufferUtils.createFloatBuffer(vertexBuffer.limit() / 3 * 4 * 2);
-
-        for (int i = 0; i < vertexBuffer.limit() / 3; i++) {
-            populateFromBuffer(origin, vertexBuffer, i);
-            populateFromBuffer(point, normalBuffer, i);
-
-            int index = i * 2;
-
-            setInBuffer(origin, lineVertex, index);
-            setInBuffer(originColor, lineColor, index);
-
-            point.multLocal(scale);
-            point.addLocal(origin);
-            setInBuffer(point, lineVertex, index + 1);
-            setInBuffer(normalColor, lineColor, index + 1);
-        }
-
-        lineMesh.setBuffer(Type.Position, 3, lineVertex);
-        lineMesh.setBuffer(Type.Color, 4, lineColor);
-
-        lineMesh.setStatic();
-        //lineMesh.setInterleaved();
-        return lineMesh;
+        return TangentUtils.genNormalLines(mesh, scale);
     }
 
-    private static Mesh genTangentLines(Mesh mesh, float scale) {
-        FloatBuffer vertexBuffer = (FloatBuffer) mesh.getBuffer(Type.Position).getData();
-        FloatBuffer normalBuffer = (FloatBuffer) mesh.getBuffer(Type.Normal).getData();
-        FloatBuffer tangentBuffer = (FloatBuffer) mesh.getBuffer(Type.Tangent).getData();
-
-        FloatBuffer binormalBuffer = null;
-        if (mesh.getBuffer(Type.Binormal) != null) {
-            binormalBuffer = (FloatBuffer) mesh.getBuffer(Type.Binormal).getData();
-        }
-
-        ColorRGBA originColor = ColorRGBA.White;
-        ColorRGBA tangentColor = ColorRGBA.Red;
-        ColorRGBA binormalColor = ColorRGBA.Green;
-        ColorRGBA normalColor = ColorRGBA.Blue;
-
-        Mesh lineMesh = new Mesh();
-        lineMesh.setMode(Mesh.Mode.Lines);
-
-        Vector3f origin = new Vector3f();
-        Vector3f point = new Vector3f();
-        Vector3f tangent = new Vector3f();
-        Vector3f normal = new Vector3f();
 
-        IntBuffer lineIndex = BufferUtils.createIntBuffer(vertexBuffer.limit() / 3 * 6);
-        FloatBuffer lineVertex = BufferUtils.createFloatBuffer(vertexBuffer.limit() * 4);
-        FloatBuffer lineColor = BufferUtils.createFloatBuffer(vertexBuffer.limit() / 3 * 4 * 4);
-
-        boolean hasParity = mesh.getBuffer(Type.Tangent).getNumComponents() == 4;
-        float tangentW = 1;
-
-        for (int i = 0; i < vertexBuffer.limit() / 3; i++) {
-            populateFromBuffer(origin, vertexBuffer, i);
-            populateFromBuffer(normal, normalBuffer, i);
-
-            if (hasParity) {
-                tangent.x = tangentBuffer.get(i * 4);
-                tangent.y = tangentBuffer.get(i * 4 + 1);
-                tangent.z = tangentBuffer.get(i * 4 + 2);
-                tangentW = tangentBuffer.get(i * 4 + 3);
-            } else {
-                populateFromBuffer(tangent, tangentBuffer, i);
-            }
-
-            int index = i * 4;
-
-            int id = i * 6;
-            lineIndex.put(id, index);
-            lineIndex.put(id + 1, index + 1);
-            lineIndex.put(id + 2, index);
-            lineIndex.put(id + 3, index + 2);
-            lineIndex.put(id + 4, index);
-            lineIndex.put(id + 5, index + 3);
-
-            setInBuffer(origin, lineVertex, index);
-            setInBuffer(originColor, lineColor, index);
-
-            point.set(tangent);
-            point.multLocal(scale);
-            point.addLocal(origin);
-            setInBuffer(point, lineVertex, index + 1);
-            setInBuffer(tangentColor, lineColor, index + 1);
-
-            // wvBinormal = cross(wvNormal, wvTangent) * -inTangent.w
-            if (binormalBuffer == null) {
-                normal.cross(tangent, point);
-                point.multLocal(-tangentW);
-                point.normalizeLocal();
-            } else {
-                populateFromBuffer(point, binormalBuffer, i);
-            }
-
-            point.multLocal(scale);
-            point.addLocal(origin);
-            setInBuffer(point, lineVertex, index + 2);
-            setInBuffer(binormalColor, lineColor, index + 2);
-
-            point.set(normal);
-            point.multLocal(scale);
-            point.addLocal(origin);
-            setInBuffer(point, lineVertex, index + 3);
-            setInBuffer(normalColor, lineColor, index + 3);
-        }
-
-        lineMesh.setBuffer(Type.Index, 1, lineIndex);
-        lineMesh.setBuffer(Type.Position, 3, lineVertex);
-        lineMesh.setBuffer(Type.Color, 4, lineColor);
-
-        lineMesh.setStatic();
-        //lineMesh.setInterleaved();
-        return lineMesh;
-    }
 }

+ 148 - 0
jme3-core/src/main/java/com/jme3/util/TangentUtils.java

@@ -31,7 +31,16 @@
  */
 package com.jme3.util;
 
+import static com.jme3.util.BufferUtils.populateFromBuffer;
+import static com.jme3.util.BufferUtils.setInBuffer;
+
+import java.nio.FloatBuffer;
+import java.nio.IntBuffer;
+
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Vector3f;
 import com.jme3.scene.*;
+import com.jme3.scene.VertexBuffer.Type;
 
 /**
  * Created by Nehon on 03/10/2016.
@@ -63,4 +72,143 @@ public class TangentUtils {
             }
         }
     }
+
+    public static Mesh genTbnLines(Mesh mesh, float scale) {
+        if (mesh.getBuffer(Type.Tangent) == null) {
+            return genNormalLines(mesh, scale);
+        } else {
+            return genTangentLines(mesh, scale);
+        }
+    }
+
+    public static Mesh genNormalLines(Mesh mesh, float scale) {
+        FloatBuffer vertexBuffer = (FloatBuffer) mesh.getBuffer(Type.Position).getData();
+        FloatBuffer normalBuffer = (FloatBuffer) mesh.getBuffer(Type.Normal).getData();
+
+        ColorRGBA originColor = ColorRGBA.White;
+        ColorRGBA normalColor = ColorRGBA.Blue;
+
+        Mesh lineMesh = new Mesh();
+        lineMesh.setMode(Mesh.Mode.Lines);
+
+        Vector3f origin = new Vector3f();
+        Vector3f point = new Vector3f();
+
+        FloatBuffer lineVertex = BufferUtils.createFloatBuffer(vertexBuffer.limit() * 2);
+        FloatBuffer lineColor = BufferUtils.createFloatBuffer(vertexBuffer.limit() / 3 * 4 * 2);
+
+        for (int i = 0; i < vertexBuffer.limit() / 3; i++) {
+            populateFromBuffer(origin, vertexBuffer, i);
+            populateFromBuffer(point, normalBuffer, i);
+
+            int index = i * 2;
+
+            setInBuffer(origin, lineVertex, index);
+            setInBuffer(originColor, lineColor, index);
+
+            point.multLocal(scale);
+            point.addLocal(origin);
+            setInBuffer(point, lineVertex, index + 1);
+            setInBuffer(normalColor, lineColor, index + 1);
+        }
+
+        lineMesh.setBuffer(Type.Position, 3, lineVertex);
+        lineMesh.setBuffer(Type.Color, 4, lineColor);
+
+        lineMesh.setStatic();
+        // lineMesh.setInterleaved();
+        return lineMesh;
+    }
+
+    public static Mesh genTangentLines(Mesh mesh, float scale) {
+        FloatBuffer vertexBuffer = (FloatBuffer) mesh.getBuffer(Type.Position).getData();
+        FloatBuffer normalBuffer = (FloatBuffer) mesh.getBuffer(Type.Normal).getData();
+        FloatBuffer tangentBuffer = (FloatBuffer) mesh.getBuffer(Type.Tangent).getData();
+
+        FloatBuffer binormalBuffer = null;
+        if (mesh.getBuffer(Type.Binormal) != null) {
+            binormalBuffer = (FloatBuffer) mesh.getBuffer(Type.Binormal).getData();
+        }
+
+        ColorRGBA originColor = ColorRGBA.White;
+        ColorRGBA tangentColor = ColorRGBA.Red;
+        ColorRGBA binormalColor = ColorRGBA.Green;
+        ColorRGBA normalColor = ColorRGBA.Blue;
+
+        Mesh lineMesh = new Mesh();
+        lineMesh.setMode(Mesh.Mode.Lines);
+
+        Vector3f origin = new Vector3f();
+        Vector3f point = new Vector3f();
+        Vector3f tangent = new Vector3f();
+        Vector3f normal = new Vector3f();
+
+        IntBuffer lineIndex = BufferUtils.createIntBuffer(vertexBuffer.limit() / 3 * 6);
+        FloatBuffer lineVertex = BufferUtils.createFloatBuffer(vertexBuffer.limit() * 4);
+        FloatBuffer lineColor = BufferUtils.createFloatBuffer(vertexBuffer.limit() / 3 * 4 * 4);
+
+        boolean hasParity = mesh.getBuffer(Type.Tangent).getNumComponents() == 4;
+        float tangentW = 1;
+
+        for (int i = 0; i < vertexBuffer.limit() / 3; i++) {
+            populateFromBuffer(origin, vertexBuffer, i);
+            populateFromBuffer(normal, normalBuffer, i);
+
+            if (hasParity) {
+                tangent.x = tangentBuffer.get(i * 4);
+                tangent.y = tangentBuffer.get(i * 4 + 1);
+                tangent.z = tangentBuffer.get(i * 4 + 2);
+                tangentW = tangentBuffer.get(i * 4 + 3);
+            } else {
+                populateFromBuffer(tangent, tangentBuffer, i);
+            }
+
+            int index = i * 4;
+
+            int id = i * 6;
+            lineIndex.put(id, index);
+            lineIndex.put(id + 1, index + 1);
+            lineIndex.put(id + 2, index);
+            lineIndex.put(id + 3, index + 2);
+            lineIndex.put(id + 4, index);
+            lineIndex.put(id + 5, index + 3);
+
+            setInBuffer(origin, lineVertex, index);
+            setInBuffer(originColor, lineColor, index);
+
+            point.set(tangent);
+            point.multLocal(scale);
+            point.addLocal(origin);
+            setInBuffer(point, lineVertex, index + 1);
+            setInBuffer(tangentColor, lineColor, index + 1);
+
+            // wvBinormal = cross(wvNormal, wvTangent) * -inTangent.w
+            if (binormalBuffer == null) {
+                normal.cross(tangent, point);
+                point.multLocal(-tangentW);
+                point.normalizeLocal();
+            } else {
+                populateFromBuffer(point, binormalBuffer, i);
+            }
+
+            point.multLocal(scale);
+            point.addLocal(origin);
+            setInBuffer(point, lineVertex, index + 2);
+            setInBuffer(binormalColor, lineColor, index + 2);
+
+            point.set(normal);
+            point.multLocal(scale);
+            point.addLocal(origin);
+            setInBuffer(point, lineVertex, index + 3);
+            setInBuffer(normalColor, lineColor, index + 3);
+        }
+
+        lineMesh.setBuffer(Type.Index, 1, lineIndex);
+        lineMesh.setBuffer(Type.Position, 3, lineVertex);
+        lineMesh.setBuffer(Type.Color, 4, lineColor);
+
+        lineMesh.setStatic();
+        // lineMesh.setInterleaved();
+        return lineMesh;
+    }
 }

+ 55 - 40
jme3-core/src/main/java/com/jme3/util/mikktspace/MikktspaceTangentGenerator.java

@@ -44,19 +44,15 @@ import java.util.logging.Level;
 import java.util.logging.Logger;
 
 /**
- * This tangent generator is highly experimental.
- * This is the Java translation of the mikktspace generator made by Morten S. Mikkelsen
- * C Source code can be found here
- * https://developer.blender.org/diffusion/B/browse/master/intern/mikktspace/mikktspace.c
- * https://developer.blender.org/diffusion/B/browse/master/intern/mikktspace/mikktspace.h
- * 
- * Mikktspace looks like the new standard of tangent generation in 3-D software.
- * Xnormal, Blender, Substance painter, and many more use it.
- * 
- * Usage is :
- * MikktspaceTangentGenerator.generate(spatial);
+ * Mikktspace is a common standard for tangent space used across many 3D software.
  * 
+ * This is the Java translation of the mikktspace generator made by Morten S. Mikkelsen C Source code can be
+ * found here https://developer.blender.org/diffusion/B/browse/master/intern/mikktspace/mikktspace.c
+ * https://developer.blender.org/diffusion/B/browse/master/intern/mikktspace/mikktspace.h
  * 
+ * Usage is : <code>
+ *  MikktspaceTangentGenerator.generate(spatial);
+ * </code>
  * 
  * @author Nehon
  */
@@ -117,38 +113,57 @@ public class MikktspaceTangentGenerator {
         } else if (s instanceof Geometry) {
             Geometry g = (Geometry) s;
             Mesh mesh = g.getMesh();
+            boolean success = generateTangents(mesh);
+            if (!success) {
+                logger.log(Level.SEVERE, "Failed to generate tangents for geometry {0}", g.getName());
+            }
+        }
+    }
 
-            if (mesh.getBuffer(Type.TexCoord) != null && mesh.getBuffer(Type.Normal) != null) {
-                Mesh.Mode mode = mesh.getMode();
-                boolean hasTriangles;
-                switch (mode) {
-                    case Points:
-                    case Lines:
-                    case LineStrip:
-                    case LineLoop:
-                        hasTriangles = false; // skip this mesh
-                        break;
-
-                    case Triangles:
-                    case TriangleFan:
-                    case TriangleStrip:
-                        hasTriangles = true;
-                        break;
-
-                    default:
-                        String message = "Tangent generation isn't implemented for mode=" + mode;
-                        throw new UnsupportedOperationException(message);
-                }
+    public static void generate(Mesh mesh) {
+        boolean success = generateTangents(mesh);
+        if (!success) {
+            logger.log(Level.SEVERE, "Failed to generate tangents for mesh {0}", mesh);
+        }
+    }
 
-                if (hasTriangles) {
-                    MikkTSpaceImpl context = new MikkTSpaceImpl(mesh);
-                    if (!genTangSpaceDefault(context)) {
-                        logger.log(Level.SEVERE, "Failed to generate tangents for geometry {0}", g.getName());
-                    }
-                    TangentUtils.generateBindPoseTangentsIfNecessary(mesh);
-                }
-            }
+    private static boolean generateTangents(Mesh mesh) {
+        Mesh.Mode mode = mesh.getMode();
+        boolean hasTriangles;
+        
+        if (mesh.getBuffer(Type.TexCoord) == null || mesh.getBuffer(Type.Normal) == null) {
+            logger.log(Level.SEVERE, "Tangent generation requires both a normal and texCoord buffer");
+            return false;
+        }    
+        
+        switch (mode) {
+            case Points:
+            case Lines:
+            case LineStrip:
+            case LineLoop:
+                hasTriangles = false; // skip this mesh
+                logger.log(Level.SEVERE, "Tangent generation requires a mesh with Triangles", mode);
+                break;
+
+            case Triangles:
+            case TriangleFan:
+            case TriangleStrip:
+            case Patch:
+                hasTriangles = true;
+                break;
+
+            default:
+                logger.log(Level.SEVERE, "Tangent generation isn't implemented for mode={0}", mode);
+                return false;
+        }
+        
+        if (hasTriangles) {
+            MikkTSpaceImpl context = new MikkTSpaceImpl(mesh);
+            genTangSpaceDefault(context);
+            TangentUtils.generateBindPoseTangentsIfNecessary(mesh);
+            return true;
         }
+        return false;
     }
     
     public static boolean genTangSpaceDefault(MikkTSpaceContext mikkTSpace) {

+ 2 - 4
jme3-core/src/main/resources/Common/MatDefs/Light/PBRLighting.frag

@@ -217,11 +217,9 @@ void main(){
         Roughness = 1.0 - glossiness;
         vec3 fZero = specularColor.xyz;
     #else
-        float specular = 0.5;
-        float nonMetalSpec = 0.08 * specular;
-        vec4 specularColor = (nonMetalSpec - nonMetalSpec * Metallic) + albedo * Metallic;
+        vec4 specularColor = (0.04 - 0.04 * Metallic) + albedo * Metallic;  // 0.04 is the standard base specular reflectance for non-metallic surfaces in PBR. While values like 0.08 can be used for different implementations, 0.04 aligns with Khronos' PBR specification.
         vec4 diffuseColor = albedo - albedo * Metallic;
-        vec3 fZero = vec3(specular);
+        vec3 fZero = mix(vec3(0.04), albedo.rgb, Metallic);
     #endif
 
     gl_FragColor.rgb = vec3(0.0);

+ 73 - 0
jme3-core/src/main/resources/Common/MatDefs/Light/modular/PBRLighting.frag

@@ -0,0 +1,73 @@
+#import "Common/ShaderLib/GLSLCompat.glsllib"
+
+// enable apis and import PBRLightingUtils
+#define ENABLE_PBRLightingUtils_getWorldPosition 1
+#define ENABLE_PBRLightingUtils_getWorldNormal 1
+#define ENABLE_PBRLightingUtils_getWorldTangent 1
+#define ENABLE_PBRLightingUtils_getTexCoord 1
+#define ENABLE_PBRLightingUtils_readPBRSurface 1
+#define ENABLE_PBRLightingUtils_computeDirectLightContribution 1
+#define ENABLE_PBRLightingUtils_computeProbesContribution 1
+
+#import "Common/ShaderLib/module/pbrlighting/PBRLightingUtils.glsllib"
+
+#ifdef DEBUG_VALUES_MODE
+    uniform int m_DebugValuesMode;
+#endif
+
+uniform vec4 g_LightData[NB_LIGHTS];
+uniform vec3 g_CameraPosition;
+
+void main(){
+    vec3 wpos = PBRLightingUtils_getWorldPosition();
+    vec3 worldViewDir = normalize(g_CameraPosition - wpos);
+    
+    // Load surface data
+    PBRSurface surface=PBRLightingUtils_readPBRSurface(worldViewDir);
+    
+    // Calculate direct lights
+    for(int i = 0;i < NB_LIGHTS; i+=3){
+        vec4 lightData0 = g_LightData[i];
+        vec4 lightData1 = g_LightData[i+1];
+        vec4 lightData2 = g_LightData[i+2];    
+        PBRLightingUtils_computeDirectLightContribution(
+          lightData0, lightData1, lightData2, 
+          surface
+        );
+    }
+
+
+    // Calculate env probes
+    PBRLightingUtils_computeProbesContribution(surface);
+
+    // Put it all together
+    gl_FragColor.rgb = vec3(0.0);
+    gl_FragColor.rgb += surface.bakedLightContribution;
+    gl_FragColor.rgb += surface.directLightContribution;
+    gl_FragColor.rgb += surface.envLightContribution;
+    gl_FragColor.rgb += surface.emission;
+    gl_FragColor.a = surface.alpha;
+
+  
+    // outputs the final value of the selected layer as a color for debug purposes. 
+    #ifdef DEBUG_VALUES_MODE
+        if(m_DebugValuesMode == 0){
+            gl_FragColor.rgb = vec3(surface.albedo);
+        }
+        else if(m_DebugValuesMode == 1){
+            gl_FragColor.rgb = vec3(surface.normal);
+        }
+        else if(m_DebugValuesMode == 2){
+            gl_FragColor.rgb = vec3(surface.roughness);
+        }
+        else if(m_DebugValuesMode == 3){
+            gl_FragColor.rgb = vec3(surface.metallic);
+        }
+        else if(m_DebugValuesMode == 4){
+            gl_FragColor.rgb = surface.ao.rgb;
+        }
+        else if(m_DebugValuesMode == 5){
+            gl_FragColor.rgb = vec3(surface.emission.rgb);          
+        }        
+    #endif   
+}

+ 321 - 0
jme3-core/src/main/resources/Common/MatDefs/Light/modular/PBRLighting.j3md

@@ -0,0 +1,321 @@
+MaterialDef PBR Lighting {
+
+    MaterialParameters {
+        Int BoundDrawBuffer
+
+        // Alpha threshold for fragment discarding
+        Float AlphaDiscardThreshold (AlphaTestFallOff)
+
+        //metallicity of the material
+        Float Metallic : 1.0
+        //Roughness of the material
+        Float Roughness : 1.0        
+        // Base material color
+        Color BaseColor : 1.0 1.0 1.0 1.0
+        // The emissive color of the object
+        Color Emissive        
+        // the emissive power
+        Float EmissivePower : 3.0        
+        // the emissive intensity
+        Float EmissiveIntensity : 2.0
+
+        // BaseColor map
+        Texture2D BaseColorMap
+
+        // Metallic map
+        Texture2D MetallicMap -LINEAR
+        
+        // Roughness Map
+        Texture2D RoughnessMap -LINEAR
+
+        //Metallic and Roughness are packed respectively in the b and g channel of a single map
+        // r: AO (if AoPackedInMRMap is true)
+        // g: Roughness
+        // b: Metallic
+        Texture2D MetallicRoughnessMap -LINEAR
+        
+        // Texture of the emissive parts of the material
+        Texture2D EmissiveMap
+
+        // Normal map
+        Texture2D NormalMap -LINEAR
+        // The scalar parameter applied to each normal vector of the normal map
+        Float NormalScale
+
+        //The type of normal map: -1.0 (DirectX), 1.0 (OpenGl)
+        Float NormalType : -1.0
+
+        // For Spec gloss pipeline
+        Boolean UseSpecGloss
+        Texture2D SpecularMap
+        Texture2D GlossinessMap
+        Texture2D SpecularGlossinessMap
+        Color Specular : 1.0 1.0 1.0 1.0
+        Float Glossiness : 1.0
+
+        // Parallax/height map
+        Texture2D ParallaxMap -LINEAR
+
+        // Specular-AA
+        Boolean UseSpecularAA : true
+        // screen space variance,Use the slider to set the strength of the geometric specular anti-aliasing effect between 0 and 1. Higher values produce a blurrier result with less aliasing.
+        Float SpecularAASigma
+        // clamping threshold,Use the slider to set a maximum value for the offset that HDRP subtracts from the smoothness value to reduce artifacts.
+        Float SpecularAAKappa
+
+        //Set to true if parallax map is stored in the alpha channel of the normal map
+        Boolean PackedNormalParallax   
+
+        //Sets the relief height for parallax mapping
+        Float ParallaxHeight : 0.05       
+
+        //Set to true to activate Steep Parallax mapping
+        Boolean SteepParallax
+
+        //Horizon fade
+        Boolean HorizonFade
+
+        // Set to Use Lightmap
+        Texture2D LightMap
+
+        // A scalar multiplier controlling the amount of occlusion applied.
+        // A value of `0.0` means no occlusion. A value of `1.0` means full occlusion.
+        Float AoStrength
+
+        // Set to use TexCoord2 for the lightmap sampling
+        Boolean SeparateTexCoord
+        // the light map is a grayscale ao map, only the r channel will be read.
+        Boolean LightMapAsAOMap
+        Boolean AoPackedInMRMap
+        //shadows
+        Int FilterMode
+        Boolean HardwareShadows
+
+        Texture2D ShadowMap0
+        Texture2D ShadowMap1
+        Texture2D ShadowMap2
+        Texture2D ShadowMap3
+        //pointLights
+        Texture2D ShadowMap4
+        Texture2D ShadowMap5
+        
+        Float ShadowIntensity
+        Vector4 Splits
+        Vector2 FadeInfo
+
+        Matrix4 LightViewProjectionMatrix0
+        Matrix4 LightViewProjectionMatrix1
+        Matrix4 LightViewProjectionMatrix2
+        Matrix4 LightViewProjectionMatrix3
+        //pointLight
+        Matrix4 LightViewProjectionMatrix4
+        Matrix4 LightViewProjectionMatrix5   
+        Vector3 LightPos
+        Vector3 LightDir
+
+        Float PCFEdge
+        Float ShadowMapSize
+
+        // For hardware skinning
+        Int NumberOfBones
+        Matrix4Array BoneMatrices
+
+        // For Morph animation
+        FloatArray MorphWeights
+        Int NumberOfMorphTargets
+        Int NumberOfTargetsBuffers
+                
+        // For instancing
+        Boolean UseInstancing
+
+        // For Vertex Color
+        Boolean UseVertexColor
+
+        Boolean BackfaceShadows : false
+
+        Boolean UseVertexColorsAsSunIntensity
+        Float StaticSunIntensity
+        Boolean BrightenIndoorShadows
+    
+        Int DebugValuesMode
+            // debugs the final value of the selected layer as a color output:         
+            // Layers:
+            //   0 - albedo (unshaded)
+            //   1 - normals
+            //   2 - roughness
+            //   3 - metallic
+            //   4 - ao
+            //   5 - emissive
+    }
+
+    Technique {
+        LightMode SinglePassAndImageBased
+        
+        VertexShader GLSL300 GLSL150 GLSL110:   Common/MatDefs/Light/modular/PBRLighting.vert
+        FragmentShader GLSL300 GLSL150 GLSL110: Common/MatDefs/Light/modular/PBRLighting.frag
+
+        WorldParameters {
+            WorldViewProjectionMatrix
+            CameraPosition
+            WorldMatrix
+            WorldNormalMatrix
+            ViewProjectionMatrix
+            ViewMatrix
+        }
+
+        Defines {  
+            BOUND_DRAW_BUFFER: BoundDrawBuffer       
+            BASECOLORMAP : BaseColorMap            
+            NORMALMAP : NormalMap
+            NORMALSCALE : NormalScale
+            METALLICMAP : MetallicMap
+            ROUGHNESSMAP : RoughnessMap
+            EMISSIVEMAP : EmissiveMap
+            EMISSIVE : Emissive
+            SPECGLOSSPIPELINE : UseSpecGloss
+            PARALLAXMAP : ParallaxMap
+            NORMALMAP_PARALLAX : PackedNormalParallax
+            STEEP_PARALLAX : SteepParallax
+            LIGHTMAP : LightMap
+            SEPARATE_TEXCOORD : SeparateTexCoord
+            DISCARD_ALPHA : AlphaDiscardThreshold                        
+            NUM_BONES : NumberOfBones                        
+            INSTANCING : UseInstancing
+            USE_PACKED_MR: MetallicRoughnessMap
+            USE_PACKED_SG: SpecularGlossinessMap
+            SPECULARMAP : SpecularMap
+            SPECULAR_AA : UseSpecularAA
+            SPECULAR_AA_SCREEN_SPACE_VARIANCE : SpecularAASigma
+            SPECULAR_AA_THRESHOLD : SpecularAAKappa
+            GLOSSINESSMAP : GlossinessMap
+            NORMAL_TYPE: NormalType
+            VERTEX_COLOR : UseVertexColor
+            AO_MAP: LightMapAsAOMap
+            AO_PACKED_IN_MR_MAP : AoPackedInMRMap
+            AO_STRENGTH : AoStrength
+            NUM_MORPH_TARGETS: NumberOfMorphTargets
+            NUM_TARGETS_BUFFERS: NumberOfTargetsBuffers
+            HORIZON_FADE: HorizonFade
+            USE_VERTEX_COLORS_AS_SUN_INTENSITY : UseVertexColorsAsSunIntensity
+            STATIC_SUN_INTENSITY : StaticSunIntensity
+            BRIGHTEN_INDOOR_SHADOWS : BrightenIndoorShadows
+            DEBUG_VALUES_MODE : DebugValuesMode
+        }
+    }
+
+
+    Technique PreShadow {
+
+        VertexShader   GLSL300 GLSL150 GLSL100:   Common/MatDefs/Shadow/PreShadow.vert
+        FragmentShader GLSL300 GLSL150 GLSL100: Common/MatDefs/Shadow/PreShadowPBR.frag
+
+        WorldParameters {
+            WorldViewProjectionMatrix
+            WorldViewMatrix
+            ViewProjectionMatrix
+            ViewMatrix
+        }
+
+        Defines {
+            BOUND_DRAW_BUFFER: BoundDrawBuffer
+            DISCARD_ALPHA : AlphaDiscardThreshold
+            NUM_BONES : NumberOfBones
+            INSTANCING : UseInstancing
+            NUM_MORPH_TARGETS: NumberOfMorphTargets
+            NUM_TARGETS_BUFFERS: NumberOfTargetsBuffers
+        }
+
+        ForcedRenderState {
+            FaceCull Off
+            DepthTest On
+            DepthWrite On
+            PolyOffset 5 3
+            ColorWrite Off
+        }
+
+    }
+
+
+    Technique PostShadow {
+        VertexShader   GLSL310 GLSL300 GLSL150 GLSL100:   Common/MatDefs/Shadow/PostShadow.vert
+        FragmentShader GLSL310 GLSL300 GLSL150 GLSL100: Common/MatDefs/Shadow/PostShadowPBR.frag
+
+        WorldParameters {
+            WorldViewProjectionMatrix
+            WorldMatrix
+            ViewProjectionMatrix
+            ViewMatrix
+        }
+
+        Defines {
+            BOUND_DRAW_BUFFER: BoundDrawBuffer
+            HARDWARE_SHADOWS : HardwareShadows
+            FILTER_MODE : FilterMode
+            PCFEDGE : PCFEdge
+            DISCARD_ALPHA : AlphaDiscardThreshold           
+            SHADOWMAP_SIZE : ShadowMapSize
+            FADE : FadeInfo
+            PSSM : Splits
+            POINTLIGHT : LightViewProjectionMatrix5
+            NUM_BONES : NumberOfBones
+            INSTANCING : UseInstancing
+            BACKFACE_SHADOWS: BackfaceShadows
+            NUM_MORPH_TARGETS: NumberOfMorphTargets
+            NUM_TARGETS_BUFFERS: NumberOfTargetsBuffers
+        }
+
+        ForcedRenderState {
+            Blend Modulate
+            DepthWrite Off                 
+            PolyOffset -0.1 0
+        }
+    }
+
+    Technique PreNormalPass {
+
+        VertexShader   GLSL300 GLSL150 GLSL100 :   Common/MatDefs/SSAO/normal.vert
+        FragmentShader GLSL300 GLSL150 GLSL100 : Common/MatDefs/SSAO/normal.frag
+
+        WorldParameters {
+            WorldViewProjectionMatrix
+            WorldViewMatrix
+            NormalMatrix
+            ViewProjectionMatrix
+            ViewMatrix
+        }
+
+        Defines {
+            BOUND_DRAW_BUFFER: BoundDrawBuffer
+            BASECOLORMAP_ALPHA : BaseColorMap            
+            NUM_BONES : NumberOfBones
+            INSTANCING : UseInstancing
+            NUM_MORPH_TARGETS: NumberOfMorphTargets
+            NUM_TARGETS_BUFFERS: NumberOfTargetsBuffers
+        }
+
+    }
+
+    Technique Glow {
+
+        VertexShader   GLSL300 GLSL150 GLSL100:   Common/MatDefs/Misc/Unshaded.vert
+        FragmentShader GLSL300 GLSL150 GLSL100: Common/MatDefs/Light/PBRGlow.frag
+
+        WorldParameters {
+            WorldViewProjectionMatrix
+            ViewProjectionMatrix
+            ViewMatrix
+        }
+
+        Defines {
+            HAS_EMISSIVEMAP : EmissiveMap
+            HAS_EMISSIVECOLOR : Emissive
+            BOUND_DRAW_BUFFER: BoundDrawBuffer
+            NEED_TEXCOORD1
+            NUM_BONES : NumberOfBones
+            INSTANCING : UseInstancing
+            NUM_MORPH_TARGETS: NumberOfMorphTargets
+            NUM_TARGETS_BUFFERS: NumberOfTargetsBuffers
+        }
+    }
+
+}

+ 75 - 0
jme3-core/src/main/resources/Common/MatDefs/Light/modular/PBRLighting.vert

@@ -0,0 +1,75 @@
+#import "Common/ShaderLib/GLSLCompat.glsllib"
+#import "Common/ShaderLib/Instancing.glsllib"
+#import "Common/ShaderLib/Skinning.glsllib"
+#import "Common/ShaderLib/MorphAnim.glsllib"
+
+uniform vec4 m_BaseColor;
+uniform vec4 g_AmbientLightColor;
+varying vec2 texCoord;
+
+#ifdef SEPARATE_TEXCOORD
+  varying vec2 texCoord2;
+  attribute vec2 inTexCoord2;
+#endif
+
+varying vec4 Color;
+
+attribute vec3 inPosition;
+attribute vec2 inTexCoord;
+attribute vec3 inNormal;
+
+#if defined (VERTEX_COLOR) || defined(USE_VERTEX_COLORS_AS_SUN_INTENSITY)
+    attribute vec4 inColor;
+#endif
+
+#if defined(USE_VERTEX_COLORS_AS_SUN_INTENSITY)
+    varying vec4 vertColors;
+#endif
+
+varying vec3 wNormal;
+varying vec3 wPosition;
+
+attribute vec4 inTangent;
+varying vec4 wTangent;
+
+void main(){
+    vec4 modelSpacePos = vec4(inPosition, 1.0);
+    vec3 modelSpaceNorm = inNormal;
+    vec3 modelSpaceTan  = inTangent.xyz;
+
+    #ifdef USE_VERTEX_COLORS_AS_SUN_INTENSITY
+        vertColors = inColor;
+    #endif
+
+    #ifdef NUM_MORPH_TARGETS
+         #if defined(NORMALMAP) && !defined(VERTEX_LIGHTING)
+            Morph_Compute(modelSpacePos, modelSpaceNorm, modelSpaceTan);
+         #else
+            Morph_Compute(modelSpacePos, modelSpaceNorm);
+         #endif
+    #endif
+
+    #ifdef NUM_BONES
+         #if defined(NORMALMAP) && !defined(VERTEX_LIGHTING)
+            Skinning_Compute(modelSpacePos, modelSpaceNorm, modelSpaceTan);
+         #else
+            Skinning_Compute(modelSpacePos, modelSpaceNorm);
+         #endif
+    #endif
+
+    gl_Position = TransformWorldViewProjection(modelSpacePos);
+    texCoord = inTexCoord;
+    #ifdef SEPARATE_TEXCOORD
+       texCoord2 = inTexCoord2;
+    #endif
+
+    wPosition = TransformWorld(modelSpacePos).xyz;
+    wNormal  = TransformWorldNormal(modelSpaceNorm);
+    wTangent = vec4(TransformWorldNormal(modelSpaceTan),inTangent.w);
+
+    Color = m_BaseColor;
+    
+    #ifdef VERTEX_COLOR                    
+        Color *= inColor;
+    #endif
+}

+ 3 - 0
jme3-core/src/main/resources/Common/ShaderLib/GLSLCompat.glsllib

@@ -2,6 +2,9 @@
   #ifdef FRAGMENT_SHADER
     precision highp float;
     precision highp int;
+    #if __VERSION__ >= 130
+      precision highp sampler2DArray;        
+    #endif
     precision highp sampler2DArray;
     precision highp sampler2DShadow;
     precision highp samplerCube;

+ 14 - 1
jme3-core/src/main/resources/Common/ShaderLib/Math.glsllib

@@ -1,4 +1,17 @@
+#ifndef __MATH_GLSLLIB__
+#define __MATH_GLSLLIB__
+
 /// Multiplies the vector by the quaternion, then returns the resultant vector.
 vec3 Math_QuaternionMult(in vec4 quat, in vec3 vec){
 	return vec + 2.0 * cross(quat.xyz, cross(quat.xyz, vec) + quat.w * vec);
-}
+}
+
+void Math_lengthAndNormalize(in vec3 vec,out float outLength,out vec3 outNormal){
+    float dotv=dot(vec,vec);
+    float invl=inversesqrt(dotv);
+    outNormal=vec*invl;
+    outLength=invl*dotv;
+}
+
+
+#endif

+ 34 - 0
jme3-core/src/main/resources/Common/ShaderLib/module/Light.glsl

@@ -0,0 +1,34 @@
+#ifndef __LIGHT_MODULE__
+#define __LIGHT_MODULE__
+
+/**
+* Defines a light
+*/
+
+
+#ifndef Light
+    #struct StdLight 
+        vec4 color;
+        vec3 position;
+        float type;
+
+        float invRadius;
+        float spotAngleCos;
+        vec3 spotDirection;
+
+        bool ready;
+        
+        float NdotL;                  // cos angle between normal and light direction
+        float NdotH;                  // cos angle between normal and half vector
+        float LdotH;                  // cos angle between light direction and half vector
+        float HdotV;                  // cos angle between view direction and half vector
+        vec3 vector;  
+        vec3 dir;
+        float fallOff;
+    #endstruct
+    #define Light StdLight
+#endif
+
+
+
+#endif

+ 39 - 0
jme3-core/src/main/resources/Common/ShaderLib/module/PBRSurface.glsl

@@ -0,0 +1,39 @@
+#ifndef __SURFACE_MODULE__
+#define __SURFACE_MODULE__
+
+#ifndef PBRSurface
+    #struct StdPBRSurface
+        // from geometry
+        vec3 position; // position in w space
+        vec3 viewDir; // view dir in worldSpace
+        vec3 geometryNormal; // normals w/o normalmap
+        vec3 normal; // normals w/ normalmap
+        bool frontFacing; //gl_FrontFacing
+        float depth;
+
+        // from texture
+        vec3 albedo;
+        float alpha;
+        float metallic;              // metallic value at the surface
+        float roughness;
+        vec3 diffuseColor;
+        vec3 specularColor;
+        vec3 fZero;
+        vec3 ao;
+        float exposure;
+        vec3 emission;
+
+
+        // computed
+        float NdotV;
+
+        // from env
+        vec3 bakedLightContribution; // light from light map or other baked sources
+        vec3 directLightContribution; // light from direct light sources
+        vec3 envLightContribution; // light from environment 
+
+        float brightestLightStrength;
+    #endstruct
+    #define PBRSurface StdPBRSurface    
+#endif
+#endif

+ 592 - 0
jme3-core/src/main/resources/Common/ShaderLib/module/pbrlighting/PBRLightingUtils.glsllib

@@ -0,0 +1,592 @@
+#ifndef __PBR_LIGHT_UTILS_MODULE__
+#define __PBR_LIGHT_UTILS_MODULE__
+
+#import "Common/ShaderLib/Math.glsllib"
+#import "Common/ShaderLib/PBR.glsllib"
+#import "Common/ShaderLib/Parallax.glsllib"
+
+#import "Common/ShaderLib/module/Light.glsl"
+#import "Common/ShaderLib/module/PBRSurface.glsl"
+
+// enable all apis
+// #define ENABLE_PBRLightingUtils_getWorldPosition 1
+// #define ENABLE_PBRLightingUtils_getWorldNormal 1
+// #define ENABLE_PBRLightingUtils_getWorldTangent 1
+// #define ENABLE_PBRLightingUtils_getTexCoord 1
+// #define ENABLE_PBRLightingUtils_newLight 1
+// #define ENABLE_PBRLightingUtils_computeLightInWorldSpace 1
+// #define ENABLE_PBRLightingUtils_readPBRSurface 1
+// #define ENABLE_PBRLightingUtils_computeDirectLight 1
+// #define ENABLE_PBRLightingUtils_computeDirectLightContribution 1
+// #define ENABLE_PBRLightingUtils_computeProbesContribution 1
+
+#if defined(ENABLE_PBRLightingUtils_readPBRSurface)||defined(ENABLE_PBRLightingUtils_getWorldPosition)
+    varying vec3 wPosition;
+#endif 
+
+#if defined(ENABLE_PBRLightingUtils_readPBRSurface)||defined(ENABLE_PBRLightingUtils_getWorldNormal)
+    varying vec3 wNormal;
+#endif 
+
+#if (defined(ENABLE_PBRLightingUtils_readPBRSurface)&&(defined(NORMALMAP)||defined(PARALLAXMAP)))||defined(ENABLE_PBRLightingUtils_getWorldTangent)
+    varying vec4 wTangent;
+#endif 
+
+#if defined(ENABLE_PBRLightingUtils_readPBRSurface)||defined(ENABLE_PBRLightingUtils_getTexCoord)
+    varying vec2 texCoord;
+    #ifdef SEPARATE_TEXCOORD
+        varying vec2 texCoord2;
+    #endif
+#endif
+
+
+
+#ifdef ENABLE_PBRLightingUtils_readPBRSurface   
+    varying vec4 Color;
+    
+    uniform vec4 g_AmbientLightColor;   
+    uniform float m_Roughness;
+    uniform float m_Metallic;
+
+
+    #ifdef BASECOLORMAP
+        uniform sampler2D m_BaseColorMap;
+    #endif
+
+    #ifdef USE_PACKED_MR
+        uniform sampler2D m_MetallicRoughnessMap;
+    #else
+        #ifdef METALLICMAP
+            uniform sampler2D m_MetallicMap;
+        #endif
+        #ifdef ROUGHNESSMAP
+            uniform sampler2D m_RoughnessMap;
+        #endif
+    #endif
+
+    #ifdef EMISSIVE
+        uniform vec4 m_Emissive;
+    #endif
+    #ifdef EMISSIVEMAP
+        uniform sampler2D m_EmissiveMap;
+    #endif
+    #if defined(EMISSIVE) || defined(EMISSIVEMAP)
+        uniform float m_EmissivePower;
+        uniform float m_EmissiveIntensity;
+    #endif 
+
+
+
+    #ifdef SPECGLOSSPIPELINE
+        uniform vec4 m_Specular;
+        uniform float m_Glossiness;
+        #ifdef USE_PACKED_SG
+            uniform sampler2D m_SpecularGlossinessMap;
+        #else
+            uniform sampler2D m_SpecularMap;
+            uniform sampler2D m_GlossinessMap;
+        #endif
+    #endif
+
+    #ifdef PARALLAXMAP
+        uniform sampler2D m_ParallaxMap;  
+    #endif
+    #if (defined(PARALLAXMAP) || (defined(NORMALMAP_PARALLAX) && defined(NORMALMAP)))
+        uniform float m_ParallaxHeight;
+    #endif
+
+    #ifdef LIGHTMAP
+        uniform sampler2D m_LightMap;
+    #endif
+
+    #ifdef AO_STRENGTH
+        uniform float m_AoStrength;
+    #endif
+    
+    #if defined(NORMALMAP) || defined(PARALLAXMAP)
+        uniform sampler2D m_NormalMap;       
+    #endif
+    #ifdef NORMALSCALE
+        uniform float m_NormalScale;
+    #endif
+
+    #ifdef DISCARD_ALPHA
+        uniform float m_AlphaDiscardThreshold;
+    #endif
+
+    #if defined(USE_VERTEX_COLORS_AS_SUN_INTENSITY)
+        varying vec4 vertColors;
+    #endif
+    #ifdef STATIC_SUN_INTENSITY
+        uniform float m_StaticSunIntensity;
+    #endif
+
+#endif
+
+#if defined(ENABLE_PBRLightingUtils_computeDirectLight) || defined(ENABLE_PBRLightingUtils_computeDirectLightContribution)
+    // Specular-AA
+    #ifdef SPECULAR_AA_SCREEN_SPACE_VARIANCE
+        uniform float m_SpecularAASigma;
+    #endif
+    #ifdef SPECULAR_AA_THRESHOLD
+        uniform float m_SpecularAAKappa;
+    #endif
+#endif
+
+
+
+
+#ifdef ENABLE_PBRLightingUtils_computeProbesContribution
+    #if NB_PROBES >= 1
+        uniform samplerCube g_PrefEnvMap;
+        uniform vec3 g_ShCoeffs[9];
+        uniform mat4 g_LightProbeData;
+    #endif
+    #if NB_PROBES >= 2
+        uniform samplerCube g_PrefEnvMap2;
+        uniform vec3 g_ShCoeffs2[9];
+        uniform mat4 g_LightProbeData2;
+    #endif
+    #if NB_PROBES == 3
+        uniform samplerCube g_PrefEnvMap3;
+        uniform vec3 g_ShCoeffs3[9];
+        uniform mat4 g_LightProbeData3;
+    #endif
+#endif
+
+#ifdef ENABLE_PBRLightingUtils_getWorldPosition
+    vec3 PBRLightingUtils_getWorldPosition(){
+        return wPosition.xyz;
+    }
+#endif
+
+#ifdef ENABLE_PBRLightingUtils_getWorldNormal
+    vec3 PBRLightingUtils_getWorldNormal(){
+        return normalize(wNormal.xyz);
+    }
+#endif
+
+#ifdef ENABLE_PBRLightingUtils_getWorldTangent
+    vec4 PBRLightingUtils_getWorldTangent(){
+        return wTangent;
+    }
+#endif
+
+#ifdef ENABLE_PBRLightingUtils_getTexCoord
+    vec2 PBRLightingUtils_getTexCoord(){
+        return texCoord;
+    }
+
+    #ifdef SEPARATE_TEXCOORD
+        vec2 PBRLightingUtils_getTexCoord2(){
+            return texCoord2;
+        }
+    #endif
+#endif
+
+
+
+#if defined(ENABLE_PBRLightingUtils_computeDirectLightContribution) || defined(ENABLE_PBRLightingUtils_newLight)
+    Light PBRLightingUtils_newLight(vec4 color, vec3 position, float type, float invRadius, float spotAngleCos, vec3 spotDirection){
+        Light l;
+        l.color = color;
+        l.position = position;
+        l.type = type;
+        l.invRadius = invRadius;
+        l.spotAngleCos = spotAngleCos;
+        l.spotDirection = spotDirection;
+        l.ready = false;
+        return l;
+    }
+#endif
+
+
+#if  defined(ENABLE_PBRLightingUtils_computeDirectLightContribution) || defined(ENABLE_PBRLightingUtils_computeLightInWorldSpace)
+    void PBRLightingUtils_computeLightInWorldSpace(vec3 worldPos,vec3 worldNormal, vec3 viewDir, inout Light light){
+        if(light.ready) return;
+
+        // lightComputeDir
+        float posLight = step(0.5, light.type);     
+        light.vector = light.position.xyz * sign(posLight - 0.5) - (worldPos * posLight); //tempVec lightVec
+
+        vec3 L; // lightDir
+        float dist;
+        Math_lengthAndNormalize(light.vector,dist,L);
+
+        float invRange=light.invRadius; // position.w
+        const float light_threshold=0.01;
+
+        #ifdef SRGB
+            light.fallOff = (1.0 - invRange * dist) / (1.0 + invRange * dist * dist); // lightDir.w
+            light.fallOff = clamp(light.fallOff, 1.0 - posLight, 1.0);
+        #else
+            light.fallOff = clamp(1.0 - invRange * dist * posLight, 0.0, 1.0);
+        #endif
+        
+        // computeSpotFalloff
+        if(light.type>1.){
+            vec3 spotdir = normalize(light.spotDirection);
+            float curAngleCos = dot(-L, spotdir);    
+            float innerAngleCos = floor(light.spotAngleCos) * 0.001;
+            float outerAngleCos = fract(light.spotAngleCos);
+            float innerMinusOuter = innerAngleCos - outerAngleCos;
+            float falloff = clamp((curAngleCos - outerAngleCos) / innerMinusOuter, 0.0, 1.0);
+            #ifdef SRGB
+                // Use quadratic falloff (notice the ^4)
+                falloff = pow(clamp((curAngleCos - outerAngleCos) / innerMinusOuter, 0.0, 1.0), 4.0);
+            #endif
+            light.fallOff*=falloff;
+        }
+
+
+        vec3 h=normalize(L+viewDir);
+        light.dir=L;
+        light.NdotL = max(dot(worldNormal, L), 0.0);
+        light.NdotH = max(dot(worldNormal, h), 0.0);
+        light.LdotH = max(dot(L, h), 0.0);
+        light.HdotV = max(dot(viewDir,h), 0.);
+    }
+#endif
+
+
+#ifdef ENABLE_PBRLightingUtils_readPBRSurface
+    PBRSurface PBRLightingUtils_readPBRSurface(    
+        in vec3 wViewDir
+    ){
+        
+        PBRSurface surface;
+        surface.position = wPosition;
+        surface.viewDir = wViewDir;
+        surface.geometryNormal = normalize(wNormal);
+
+        surface.bakedLightContribution = vec3(0);
+        surface.directLightContribution = vec3(0);
+        surface.envLightContribution = vec3(0);
+
+        #if defined(NORMALMAP) || defined(PARALLAXMAP)
+            vec3 tan = normalize(wTangent.xyz);
+            mat3 tbnMat = mat3(tan, wTangent.w * cross( surface.geometryNormal, tan), surface.geometryNormal);
+        #endif
+        
+        vec2 newTexCoord;
+        #if (defined(PARALLAXMAP) || (defined(NORMALMAP_PARALLAX) && defined(NORMALMAP)))
+            vec3 vViewDir =  wViewDir * tbnMat;  
+            #ifdef STEEP_PARALLAX
+                #ifdef NORMALMAP_PARALLAX
+                    //parallax map is stored in the alpha channel of the normal map         
+                    newTexCoord = steepParallaxOffset(m_NormalMap, vViewDir, texCoord, m_ParallaxHeight);
+                #else
+                    //parallax map is a texture
+                    newTexCoord = steepParallaxOffset(m_ParallaxMap, vViewDir, texCoord, m_ParallaxHeight);         
+                #endif
+            #else
+                #ifdef NORMALMAP_PARALLAX
+                    //parallax map is stored in the alpha channel of the normal map         
+                    newTexCoord = classicParallaxOffset(m_NormalMap, vViewDir, texCoord, m_ParallaxHeight);
+                #else
+                //parallax map is a texture
+                newTexCoord = classicParallaxOffset(m_ParallaxMap, vViewDir, texCoord, m_ParallaxHeight);
+            #endif
+        #endif
+        #else
+        newTexCoord = texCoord;    
+        #endif
+        
+        #ifdef BASECOLORMAP
+            vec4 baseColor = texture2D(m_BaseColorMap, newTexCoord) * Color;
+        #else
+            vec4 baseColor = Color;
+        #endif
+        
+        #ifdef DISCARD_ALPHA
+            if( baseColor.a < m_AlphaDiscardThreshold) discard;        
+        #endif
+
+        surface.albedo = baseColor.rgb;
+        surface.alpha = baseColor.a;
+
+        
+
+        //ao in r channel, roughness in green channel, metallic in blue channel!
+        vec3 aoRoughnessMetallicValue = vec3(1.0, 1.0, 0.0);
+        #ifdef USE_PACKED_MR
+            aoRoughnessMetallicValue = texture2D(m_MetallicRoughnessMap, newTexCoord).rgb;
+            surface.roughness = aoRoughnessMetallicValue.g * max(m_Roughness, 1e-4);
+            surface.metallic = aoRoughnessMetallicValue.b * max(m_Metallic, 0.0);
+        #else
+            #ifdef ROUGHNESSMAP
+                surface.roughness = texture2D(m_RoughnessMap, newTexCoord).r * max(m_Roughness, 1e-4);
+            #else
+                surface.roughness =  max(m_Roughness, 1e-4);
+            #endif
+            #ifdef METALLICMAP
+                surface.metallic = texture2D(m_MetallicMap, newTexCoord).r * max(m_Metallic, 0.0);
+            #else
+                surface.metallic =  max(m_Metallic, 0.0);
+            #endif
+        #endif
+    
+
+        
+        #if defined(NORMALMAP)
+            vec4 normalHeight = texture2D(m_NormalMap, newTexCoord);
+            // Note we invert directx style normal maps to opengl style
+            #ifdef NORMALSCALE
+                vec3 normal = normalize((normalHeight.xyz * vec3(2.0, NORMAL_TYPE * 2.0, 2.0) - vec3(1.0, NORMAL_TYPE * 1.0, 1.0)) * vec3(m_NormalScale, m_NormalScale, 1.0));
+            #else
+                vec3 normal = normalize((normalHeight.xyz * vec3(2.0, NORMAL_TYPE * 2.0, 2.0) - vec3(1.0, NORMAL_TYPE * 1.0, 1.0)));
+            #endif
+            surface.normal = normalize(tbnMat * normal);
+        #else 
+            surface.normal = normal;
+        #endif
+        
+        //spec gloss tex reads:
+        
+        #ifdef SPECGLOSSPIPELINE
+            #ifdef USE_PACKED_SG
+                vec4 specularColor = texture2D(m_SpecularGlossinessMap, newTexCoord);
+                float glossiness = specularColor.a * m_Glossiness;
+                specularColor *= m_Specular;
+            #else
+                #ifdef SPECULARMAP
+                    vec4 specularColor = texture2D(m_SpecularMap, newTexCoord);
+                #else
+                    vec4 specularColor = vec4(1.0);
+                #endif
+                #ifdef GLOSSINESSMAP
+                    float glossiness = texture2D(m_GlossinesMap, newTexCoord).r * m_Glossiness;
+                #else
+                    float glossiness = m_Glossiness;
+                #endif
+                specularColor *= m_Specular;
+            #endif
+            surface.diffuseColor = surface.albedo;// * (1.0 - max(max(specularColor.r, specularColor.g), specularColor.b));
+            surface.roughness = 1.0 - glossiness;
+            surface.fZero = specularColor.xyz;
+            surface.specularColor = specularColor;
+        #else
+            float specular = 0.5;
+            float nonMetalSpec = 0.08 * specular;
+            surface.specularColor = (nonMetalSpec - nonMetalSpec * surface.metallic) + surface.albedo * surface.metallic;
+            surface.diffuseColor = surface.albedo - surface.albedo * surface.metallic;
+            surface.fZero = vec3(specular);
+        #endif
+        
+
+        vec3 ao=vec3(1.0);
+        #ifdef LIGHTMAP
+            vec3 lightMapColor;
+            #ifdef SEPARATE_TEXCOORD
+                lightMapColor = texture2D(m_LightMap, texCoord2).rgb;
+            #else
+                lightMapColor = texture2D(m_LightMap, texCoord).rgb;
+            #endif               
+            #ifdef AO_MAP
+                lightMapColor.gb = lightMapColor.rr; 
+                ao = lightMapColor;
+            #else  
+                surface.bakedLightContribution +=  diffuseColor.rgb * lightMapColor;
+            #endif
+            surface.specularColor.rgb *= lightMapColor;
+        #endif
+        
+        #if defined(AO_PACKED_IN_MR_MAP) && defined(USE_PACKED_MR) 
+            ao = aoRoughnessMetallicValue.rrr; //note that this will override the AO value if it was previously read from a lightMap that is being used as AO_Map above. so don't try to use an AO map packed in metallic roughness while also using lightmap as ao map
+        #endif
+
+        #ifdef AO_STRENGTH
+            ao = 1.0 + m_AoStrength * (ao - 1.0);
+            // sanity check
+            ao = clamp(ao, 0.0, 1.0);
+        #endif
+        surface.ao=ao;
+        
+        #if defined(EMISSIVE) || defined (EMISSIVEMAP)
+            #ifdef EMISSIVEMAP
+                vec4 emissive = texture2D(m_EmissiveMap, newTexCoord);
+                #ifdef EMISSIVE
+                    emissive *= m_Emissive;
+                #endif
+            #else
+                vec4 emissive = m_Emissive;
+            #endif
+            surface.emission = emissive.rgb * pow(emissive.a, m_EmissivePower) * m_EmissiveIntensity;
+        #else 
+            surface.emission = vec3(0);
+        #endif
+        
+        #if defined(STATIC_SUN_INTENSITY)
+            surface.exposure = m_StaticSunIntensity; //single float value to indicate percentage of sunlight hitting the model (only suitable for small models or models with equal sunlight exposure accross the entire model
+        #elif defined(USE_VERTEX_COLORS_AS_SUN_INTENSITY)
+            surface.exposure = vertColors.r;    // use red channel of vertexColors for non-uniform sunlighting accross a single model
+        #else 
+            surface.exposure = 1.0; //default value
+        #endif 
+        
+        surface.frontFacing = gl_FrontFacing;
+        surface.depth = gl_FragCoord.z;
+
+
+        // surface.alphaRoughness = clamp(surface.roughness * surface.roughness, minRoughness, 1.0);
+        surface.NdotV = clamp(abs(dot(!surface.frontFacing?-surface.normal:surface.normal, surface.viewDir)), 0.001, 1.0);
+        // surface.reflectedVec = normalize(reflect(-surface.viewDir, surface.normal));
+
+        surface.brightestLightStrength=0.0;
+        return surface;
+    }
+#endif
+
+
+#if defined(ENABLE_PBRLightingUtils_computeDirectLight) || defined(ENABLE_PBRLightingUtils_computeDirectLightContribution)
+    void PBRLightingUtils_computeDirectLight(in Light light, in PBRSurface surface, inout vec3 directDiffuse, inout vec3 directSpecular, out float hdotv){
+
+        #ifdef SPECULAR_AA
+            #ifdef SPECULAR_AA_SCREEN_SPACE_VARIANCE
+                float sigma = m_SpecularAASigma;
+            #else 
+                float sigma = 1.0;
+            #endif
+            
+            #ifdef SPECULAR_AA_THRESHOLD
+                float kappa = m_SpecularAAKappa;
+            #else
+                float kappa = 0.18;
+            #endif    
+            
+            hdotv = PBR_ComputeDirectLightWithSpecularAA(
+                                    surface.normal,
+                                    light.dir.xyz, 
+                                    surface.viewDir,
+                                    light.color.rgb, 
+                                    surface.fZero, 
+                                    surface.roughness, 
+                                    sigma,
+                                    kappa, 
+                                    surface.NdotV,
+                                    directDiffuse,  
+                                    directSpecular
+                    );
+        #else
+            hdotv = PBR_ComputeDirectLight(
+                                    surface.normal, 
+                                    light.dir.xyz, 
+                                    surface.viewDir,
+                                    light.color.rgb, 
+                                    surface.fZero, 
+                                    surface.roughness, 
+                                    surface.NdotV,
+                                    directDiffuse,  
+                                    directSpecular
+                    );
+        #endif
+
+    }
+#endif
+
+ 
+#ifdef ENABLE_PBRLightingUtils_computeDirectLightContribution
+    void PBRLightingUtils_computeDirectLightContribution(
+        in vec4 lightData0, 
+        in vec4 lightData1, 
+        in vec4 lightData2,
+        inout PBRSurface surface
+    ){
+        vec4 lightColor = vec4(lightData0.rgb,1.0);
+        float lightType = lightData0.w;  
+
+        vec3 lightPosition = lightData1.xyz;
+        float lightInvRadius = lightData1.w;
+        
+        vec3 spotDirection = lightData2.xyz;
+        float spotAngleCos = lightData2.w;
+
+        Light light = PBRLightingUtils_newLight(lightColor, lightPosition, lightType, lightInvRadius, spotAngleCos, spotDirection);
+        PBRLightingUtils_computeLightInWorldSpace(surface.position, surface.normal, surface.viewDir, light);
+
+        vec3 directDiffuse;
+        vec3 directSpecular;
+        float hdotv;
+        PBRLightingUtils_computeDirectLight(light, surface, directDiffuse, directSpecular, hdotv); 
+
+        vec3 directLighting = surface.diffuseColor.rgb * directDiffuse + directSpecular;
+
+        #if defined(USE_VERTEX_COLORS_AS_SUN_INTENSITY) || defined(STATIC_SUN_INTENSITY)         
+            if(light.fallOff == 1.0){
+                directLighting.rgb *= surface.exposure;//  used to scale down how intense just the sun is indoors, and so the ambientLighting can be scaled back up indoors based on nearest pointlight intensity (ambient and direct light are 1.0 fallOff)                
+            } else{
+                surface.brightestLightStrength = max(light.fallOff, surface.brightestLightStrength);
+            }
+        #endif
+
+        surface.directLightContribution.rgb += directLighting * light.fallOff;
+    }
+#endif
+
+#ifdef ENABLE_PBRLightingUtils_computeProbesContribution
+    void PBRLightingUtils_computeProbesContribution(inout PBRSurface surface){   
+
+        #ifdef BRIGHTEN_INDOOR_SHADOWS
+            float minVertLighting = 0.0833; //enable this when using shadows, in order to brighten indoor areas (which are naturally covered from the DL shadows) so that indoor areas are not way too dark when using IndoorLighting with shadows compared to when shadows are off
+        #else
+            float minVertLighting = 0.0533;
+        #endif
+        
+        float finalLightingScale=1.0;
+        finalLightingScale = max(finalLightingScale, surface.brightestLightStrength);    
+        finalLightingScale = max(finalLightingScale, minVertLighting); //essentially just the vertColors.r (aka indoor light exposure) multiplied by the time of day scale.   
+
+
+        #if NB_PROBES > 0
+            float probeNdfSum=0;
+            float invProbeNdfSum=0;    
+            
+            #for i=1..4 ( #if NB_PROBES >= $i $0 #endif )
+                vec3 probeColor$i;
+                float probeNdf$i = renderProbe(
+                    surface.viewDir, 
+                    surface.position, 
+                    surface.normal,
+                    surface.geometryNormal,
+                    surface.roughness,
+                    vec4(surface.diffuseColor,1.0),
+                    vec4(surface.specularColor,1.0), 
+                    surface.NdotV, 
+                    surface.ao, 
+                    #if $i == 1
+                        g_LightProbeData, 
+                    #else  
+                        g_LightProbeData$i,
+                    #endif
+                    g_ShCoeffs,
+                    g_PrefEnvMap,
+                    probeColor$i
+                );
+                float probeInvNdf$i =  max(1.0 - probeNdf$i,0.0);
+                probeNdfSum += probeNdf$i;
+                invProbeNdfSum += probeInvNdf$i;
+                #ifdef USE_AMBIENT_LIGHT
+                    probeColor$i.rgb *= g_AmbientLightColor.rgb;
+                #endif
+                probeColor$i.rgb *= finalLightingScale;
+            #endfor
+
+            #if NB_PROBES > 1
+                float probeWeightSum=0;
+                #for i=1..4 ( #if NB_PROBES >= $i $0 #endif )
+                    float probeWeight$i = ((1.0 - (probeNdf$i / probeNdfSum)) / (NB_PROBES - 1)) *  ( probeInvNdf$i / invProbeNdfSum);
+                    probeWeightSum += probeWeight$i;    
+                #endfor 
+
+                #for i=1..4 ( #if NB_PROBES >= $i $0 #endif )       
+                    surface.envLightContribution.rgb += probeColor$i * clamp( probeWeight$i / probeWeightSum, 0., 1.);
+                #endfor 
+            #else
+                surface.envLightContribution.rgb += probeColor1;
+            #endif
+        #endif
+    }
+#endif
+
+
+#endif

+ 33 - 10
jme3-core/src/test/java/com/jme3/anim/AnimComposerTest.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2019 jMonkeyEngine
+ * Copyright (c) 2009-2024 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -32,6 +32,7 @@
 package com.jme3.anim;
 
 import com.jme3.anim.tween.action.Action;
+import com.jme3.util.clone.Cloner;
 import java.util.Set;
 import java.util.TreeSet;
 import org.junit.Assert;
@@ -57,34 +58,34 @@ public class AnimComposerTest {
         Assert.assertNotNull(composer.getAnimClipsNames());
         Assert.assertEquals(0, composer.getAnimClipsNames().size());
     }
-    
+
     @Test
     public void testMakeLayer() {
         AnimComposer composer = new AnimComposer();
-        
+
         final String layerName = "TestLayer";
 
         composer.makeLayer(layerName, null);
-        
+
         final Set<String> layers = new TreeSet<>();
         layers.add("Default");
         layers.add(layerName);
-        
+
         Assert.assertNotNull(composer.getLayer(layerName));
         Assert.assertEquals(layers, composer.getLayerNames());
     }
-    
+
     @Test
     public void testMakeAction() {
         AnimComposer composer = new AnimComposer();
-        
+
         final String animName = "TestClip";
-        
+
         final AnimClip anim = new AnimClip(animName);
         composer.addAnimClip(anim);
-        
+
         final Action action = composer.makeAction(animName);
-        
+
         Assert.assertNotNull(action);
     }
 
@@ -102,4 +103,26 @@ public class AnimComposerTest {
         composer.getAnimClipsNames().add("test");
     }
 
+    @Test
+    public void testHasDefaultLayer() {
+        AnimComposer composer = new AnimComposer();
+
+        AnimLayer defaultLayer = composer.getLayer("Default");
+        Assert.assertNotNull(defaultLayer);
+    }
+
+    @Test
+    /**
+     * https://github.com/jMonkeyEngine/jmonkeyengine/issues/2341
+     *
+     */
+    public void testMissingDefaultLayerIssue2341() {
+        AnimComposer composer = new AnimComposer();
+        composer.removeLayer(AnimComposer.DEFAULT_LAYER);
+
+        AnimComposer clone = (AnimComposer) composer.jmeClone();
+        clone.cloneFields(new Cloner(), composer);
+        Assert.assertNotNull(clone.getLayer(AnimComposer.DEFAULT_LAYER));
+    }
+
 }

+ 48 - 0
jme3-core/src/test/java/com/jme3/shader/GLSLPreprocessorTest.java

@@ -75,4 +75,52 @@ public class GLSLPreprocessorTest {
         String sourceCheck=readAllAsString(testData.openStream());
         assertEquals(sourceCheck, processedSource);                  
     }
+
+    @Test
+    public void testStruct() throws Exception {
+            String source = "// nothing\n#struct MyStruct \n" + "  float x ;//nothing  \n" + "     float y;\n"
+                            + "#endstruct\n//nothing";
+            String processedSource = readAllAsString(
+                            Preprocessor.apply(new ByteArrayInputStream(source.getBytes("UTF-8"))));
+            System.out.println(processedSource);
+            AssetInfo testData = TestUtil.createAssetManager()
+                            .locateAsset(new AssetKey("GLSLPreprocessorTest.testStruct.validOutput"));
+            assertNotNull(testData);
+            String sourceCheck = readAllAsString(testData.openStream());
+            assertEquals(sourceCheck, processedSource);
+    }
+
+    @Test
+    public void testStructExtends() throws Exception {
+            String source = "// nothing\n#struct BaseStruct \n" + "  float x0;\n" + "  float y0;\n"
+                            + "#endstruct\n//nothing\n";
+            source += "//nothing\n#struct MyStruct extends BaseStruct \n" + "  float x;\n" + "  float y;\n"
+                            + "#endstruct\n//nothing\n";
+            String processedSource = readAllAsString(
+                            Preprocessor.apply(new ByteArrayInputStream(source.getBytes("UTF-8"))));
+            System.out.println(processedSource);
+            AssetInfo testData = TestUtil.createAssetManager()
+                            .locateAsset(new AssetKey("GLSLPreprocessorTest.testStructExtends.validOutput"));
+            assertNotNull(testData);
+            String sourceCheck = readAllAsString(testData.openStream());
+            assertEquals(sourceCheck, processedSource);
+    }
+
+    @Test
+    public void testStructExtendsMulti() throws Exception {
+            String source = "#struct BaseStruct \n" + "  float x0;\n" + "  float y0;\n" + "#endstruct\n";
+            source += "#struct BaseStruct2 \n" + "  float x1;\n" + "  float y1;\n"
+                            + "#endstruct\n//nothing\n";
+            source += "#struct MyStruct extends BaseStruct, BaseStruct2\n" + "  float x;\n" + "  float y;\n"
+                            + "#endstruct\n";
+            String processedSource = readAllAsString(
+                            Preprocessor.apply(new ByteArrayInputStream(source.getBytes("UTF-8"))));
+            System.out.println(processedSource);
+            AssetInfo testData = TestUtil.createAssetManager().locateAsset(
+                            new AssetKey("GLSLPreprocessorTest.testStructExtendsMulti.validOutput"));
+            assertNotNull(testData);
+            String sourceCheck = readAllAsString(testData.openStream());
+            assertEquals(sourceCheck, processedSource);
+    }
+
 }

+ 8 - 0
jme3-core/src/test/resources/GLSLPreprocessorTest.testStruct.validOutput

@@ -0,0 +1,8 @@
+// nothing
+#define STRUCT_MyStruct \
+float x ; \
+float y;
+struct MyStruct { 
+STRUCT_MyStruct
+};
+//nothing

+ 17 - 0
jme3-core/src/test/resources/GLSLPreprocessorTest.testStructExtends.validOutput

@@ -0,0 +1,17 @@
+// nothing
+#define STRUCT_BaseStruct \
+float x0; \
+float y0;
+struct BaseStruct { 
+STRUCT_BaseStruct
+};
+//nothing
+//nothing
+#define STRUCT_MyStruct \
+STRUCT_BaseStruct \
+float x; \
+float y;
+struct MyStruct { 
+STRUCT_MyStruct
+};
+//nothing

+ 21 - 0
jme3-core/src/test/resources/GLSLPreprocessorTest.testStructExtendsMulti.validOutput

@@ -0,0 +1,21 @@
+#define STRUCT_BaseStruct \
+float x0; \
+float y0;
+struct BaseStruct { 
+STRUCT_BaseStruct
+};
+#define STRUCT_BaseStruct2 \
+float x1; \
+float y1;
+struct BaseStruct2 { 
+STRUCT_BaseStruct2
+};
+//nothing
+#define STRUCT_MyStruct \
+STRUCT_BaseStruct \
+STRUCT_BaseStruct2 \
+float x; \
+float y;
+struct MyStruct { 
+STRUCT_MyStruct
+};

+ 93 - 0
jme3-core/src/tools/java/jme3tools/shader/Preprocessor.java

@@ -58,6 +58,7 @@ public class Preprocessor {
         String code = bos.toString("UTF-8");
 
         code = Preprocessor.forMacro(code);
+        code = Preprocessor.structMacro(code);
 
         return new ByteArrayInputStream(code.getBytes("UTF-8"));
     }
@@ -118,4 +119,96 @@ public class Preprocessor {
         return code;
     }
 
+    /**
+     * <code>
+     * #struct MyStruct extends BaseStruct, BaseStruct2
+     *  int i; 
+     *  int b; 
+     * #endstruct
+     * </code>
+     */
+    // match #struct MyStruct extends BaseStruct, BaseStruct2
+    // extends is optional
+    // private static final Pattern FOR_REGEX = Pattern
+    // .compile("([^=]+)=\\s*([0-9]+)\\s*\\.\\.\\s*([0-9]+)\\s*\\((.+)\\)");
+
+    private static final Pattern STRUCT_REGEX = Pattern
+            .compile("(\\w+)(?:\\s+extends\\s+(\\w+(?:,\\s*\\w+)*))?");
+
+    public static String structMacro(String code) {
+        StringBuilder expandedCode = new StringBuilder();
+        StringBuilder currentStruct = null;
+        String structDec = null;
+        int skip = 0;
+        String[] codeLines = code.split("\n");
+        boolean captured = false;
+        for (String line : codeLines) {
+            if (!captured) {
+                String trimmedLine = line.trim();
+                if (trimmedLine.startsWith("#struct")) {
+                    if (skip == 0) {
+                        structDec = trimmedLine;
+                        currentStruct = new StringBuilder();
+                        skip++;
+                        continue;
+                    }
+                    skip++;
+                } else if (trimmedLine.startsWith("#endstruct")) {
+                    skip--;
+                    if (skip == 0) {
+                        structDec = structDec.substring("#struct ".length()).trim();
+
+                        Matcher matcher = STRUCT_REGEX.matcher(structDec);
+                        if (matcher.matches()) {
+                            String structName = matcher.group(1);
+                            if (structName == null) structName = "";
+
+                            String extendsStructs = matcher.group(2);
+                            String extendedStructs[];
+                            if (extendsStructs != null) {
+                                extendedStructs = extendsStructs.split(",\\s*");
+                            } else {
+                                extendedStructs = new String[0];
+                            }
+                            String structBody = currentStruct.toString();
+                            if (structBody == null) structBody = "";
+                            else {
+                                // remove tail spaces
+                                structBody = structBody.replaceAll("\\s+$", "");
+                            }
+
+                            currentStruct = null;
+                            expandedCode.append("#define STRUCT_").append(structName).append(" \\\n");
+                            for (String extendedStruct : extendedStructs) {
+                                expandedCode.append("STRUCT_").append(extendedStruct).append(" \\\n");
+                            }
+                            String structBodyLines[] = structBody.split("\n");
+                            for (int i = 0; i < structBodyLines.length; i++) {
+                                String structBodyLine = structBodyLines[i];
+                                structBodyLine = structBodyLine.trim();
+                                if (structBodyLine == "") continue;
+                                // remove comments if any
+                                int commentIndex = structBodyLine.indexOf("//");
+                                if (commentIndex >= 0)
+                                    structBodyLine = structBodyLine.substring(0, commentIndex);
+                                expandedCode.append(structBodyLine);
+                                if (i < structBodyLines.length - 1) expandedCode.append(" \\");
+                                expandedCode.append("\n");
+                            }
+                            expandedCode.append("struct ").append(structName).append(" { \nSTRUCT_")
+                                    .append(structName).append("\n};\n");
+                            captured = true;
+                            continue;
+                        }
+                    }
+                }
+            }
+            if (currentStruct != null) currentStruct.append(line).append("\n");
+            else expandedCode.append(line).append("\n");
+        }
+        code = expandedCode.toString();
+        if (captured) code = structMacro(code);
+        return code;
+    }
+
 }

+ 181 - 172
jme3-desktop/src/main/java/com/jme3/system/AWTContext.java

@@ -31,7 +31,6 @@
  */
 package com.jme3.system;
 
-
 import com.jme3.input.AWTKeyInput;
 import com.jme3.input.AWTMouseInput;
 import com.jme3.input.JoyInput;
@@ -49,104 +48,104 @@ import com.jme3.renderer.Renderer;
  */
 public class AWTContext implements JmeContext {
 
-  /**
-   * The settings.
-   */
-  protected final AppSettings settings;
-
-  /**
-   * The key input.
-   */
-  protected final AWTKeyInput keyInput;
-
-  /**
-   * The mouse input.
-   */
-  protected final AWTMouseInput mouseInput;
-
-  /**
-   * The current width.
-   */
-  private volatile int width;
-
-  /**
-   * The current height.
-   */
-  private volatile int height;
-
-  /**
-   * The background context.
-   */
-  protected JmeContext backgroundContext;
-
-  public AWTContext() {
-      this.keyInput = new AWTKeyInput(this);
-      this.mouseInput = new AWTMouseInput(this);
-      this.settings = createSettings();
-      this.backgroundContext = createBackgroundContext();
-      this.height = 1;
-      this.width = 1;
-  }
-
-  /**
-   * @return the current height.
-   */
-  public int getHeight() {
-      return height;
-  }
-
-  /**
-   * @param height the current height.
-   */
-  public void setHeight(final int height) {
-      this.height = height;
-  }
-
-  /**
-   * @return the current width.
-   */
-  public int getWidth() {
-      return width;
-  }
-
-  /**
-   * @param width the current width.
-   */
-  public void setWidth(final int width) {
-      this.width = width;
-  }
-
-  /**
-   * @return new settings.
-   */
-  protected AppSettings createSettings() {
-      final AppSettings settings = new AppSettings(true);
-      settings.setRenderer(AppSettings.LWJGL_OPENGL32);
-      return settings;
-  }
-
-  /**
-   * @return new context/
-   */
-  protected JmeContext createBackgroundContext() {
-      return JmeSystem.newContext(settings, Type.OffscreenSurface);
-  }
-
-  @Override
-  public Type getType() {
-      return Type.OffscreenSurface;
-  }
-
-  @Override
-  public void setSettings(AppSettings settings) {
-      this.settings.copyFrom(settings);
-      this.settings.setRenderer(AppSettings.LWJGL_OPENGL32);
-      this.backgroundContext.setSettings(settings);
-  }
+    /**
+     * The settings.
+     */
+    protected final AppSettings settings;
+
+    /**
+     * The key input.
+     */
+    protected final AWTKeyInput keyInput;
+
+    /**
+     * The mouse input.
+     */
+    protected final AWTMouseInput mouseInput;
+
+    /**
+     * The current width.
+     */
+    private volatile int width;
+
+    /**
+     * The current height.
+     */
+    private volatile int height;
+
+    /**
+     * The background context.
+     */
+    protected JmeContext backgroundContext;
+
+    public AWTContext() {
+        this.keyInput = new AWTKeyInput(this);
+        this.mouseInput = new AWTMouseInput(this);
+        this.settings = createSettings();
+        this.backgroundContext = createBackgroundContext();
+        this.height = 1;
+        this.width = 1;
+    }
+
+    /**
+     * @return the current height.
+     */
+    public int getHeight() {
+        return height;
+    }
+
+    /**
+     * @param height the current height.
+     */
+    public void setHeight(final int height) {
+        this.height = height;
+    }
+
+    /**
+     * @return the current width.
+     */
+    public int getWidth() {
+        return width;
+    }
+
+    /**
+     * @param width the current width.
+     */
+    public void setWidth(final int width) {
+        this.width = width;
+    }
+
+    /**
+     * @return new settings.
+     */
+    protected AppSettings createSettings() {
+        final AppSettings settings = new AppSettings(true);
+        settings.setRenderer(AppSettings.LWJGL_OPENGL32);
+        return settings;
+    }
+
+    /**
+     * @return new context/
+     */
+    protected JmeContext createBackgroundContext() {
+        return JmeSystem.newContext(settings, Type.OffscreenSurface);
+    }
+
+    @Override
+    public Type getType() {
+        return Type.OffscreenSurface;
+    }
+
+    @Override
+    public void setSettings(AppSettings settings) {
+        this.settings.copyFrom(settings);
+        this.settings.setRenderer(AppSettings.LWJGL_OPENGL32);
+        this.backgroundContext.setSettings(settings);
+    }
 
     /**
      * Accesses the listener that receives events related to this context.
-    *
+     *
      * @return the pre-existing instance
      */
     @Override
@@ -154,87 +153,85 @@ public class AWTContext implements JmeContext {
         return backgroundContext.getSystemListener();
     }
 
-  @Override
-  public void setSystemListener(final SystemListener listener) {
-      backgroundContext.setSystemListener(listener);
-  }
-
-  @Override
-  public AppSettings getSettings() {
-      return settings;
-  }
-
-  @Override
-  public Renderer getRenderer() {
-      return backgroundContext.getRenderer();
-  }
-
-  @Override
-  public Context getOpenCLContext() {
-      return null;
-  }
-
-  @Override
-  public AWTMouseInput getMouseInput() {
-      return mouseInput;
-  }
-
-  @Override
-  public AWTKeyInput getKeyInput() {
-      return keyInput;
-  }
-
-  @Override
-  public JoyInput getJoyInput() {
-      return null;
-  }
-
-  @Override
-  public TouchInput getTouchInput() {
-      return null;
-  }
-
-  @Override
-  public Timer getTimer() {
-      return backgroundContext.getTimer();
-  }
-
-  @Override
-  public void setTitle(final String title) {
-  }
-
-  @Override
-  public boolean isCreated() {
-      return backgroundContext != null && backgroundContext.isCreated();
-  }
-
-  @Override
-  public boolean isRenderable() {
-      return backgroundContext != null && backgroundContext.isRenderable();
-  }
-
-  @Override
-  public void setAutoFlushFrames(final boolean enabled) {
-      // TODO Auto-generated method stub
-  }
-
-  @Override
-  public void create(final boolean waitFor) {
+    @Override
+    public void setSystemListener(final SystemListener listener) {
+        backgroundContext.setSystemListener(listener);
+    }
+
+    @Override
+    public AppSettings getSettings() {
+        return settings;
+    }
+
+    @Override
+    public Renderer getRenderer() {
+        return backgroundContext.getRenderer();
+    }
+
+    @Override
+    public Context getOpenCLContext() {
+        return null;
+    }
+
+    @Override
+    public AWTMouseInput getMouseInput() {
+        return mouseInput;
+    }
+
+    @Override
+    public AWTKeyInput getKeyInput() {
+        return keyInput;
+    }
+
+    @Override
+    public JoyInput getJoyInput() {
+        return null;
+    }
+
+    @Override
+    public TouchInput getTouchInput() {
+        return null;
+    }
+
+    @Override
+    public Timer getTimer() {
+        return backgroundContext.getTimer();
+    }
+
+    @Override
+    public void setTitle(final String title) {}
+
+    @Override
+    public boolean isCreated() {
+        return backgroundContext != null && backgroundContext.isCreated();
+    }
+
+    @Override
+    public boolean isRenderable() {
+        return backgroundContext != null && backgroundContext.isRenderable();
+    }
+
+    @Override
+    public void setAutoFlushFrames(final boolean enabled) {
+        // TODO Auto-generated method stub
+    }
+
+    @Override
+    public void create(final boolean waitFor) {
         String render = System.getProperty("awt.background.render", AppSettings.LWJGL_OPENGL33);
         backgroundContext.getSettings().setRenderer(render);
         backgroundContext.create(waitFor);
-  }
+    }
 
-  @Override
-  public void restart() {
-  }
+    @Override
+    public void restart() {}
 
-  @Override
-  public void destroy(final boolean waitFor) {
-      if (backgroundContext == null) throw new IllegalStateException("Not created");
-      // destroy wrapped context
-      backgroundContext.destroy(waitFor);
-}
+    @Override
+    public void destroy(final boolean waitFor) {
+        if (backgroundContext == null) throw new IllegalStateException("Not created");
+        // destroy wrapped context
+        backgroundContext.destroy(waitFor);
+    }
 
     /**
      * Returns the height of the framebuffer.
@@ -275,4 +272,16 @@ public class AWTContext implements JmeContext {
     public int getWindowYPosition() {
         throw new UnsupportedOperationException("not implemented yet");
     }
+
+    @Override
+    public Displays getDisplays() {
+        // TODO Auto-generated method stub
+        return null;
+    }
+
+    @Override
+    public int getPrimaryDisplay() {
+        // TODO Auto-generated method stub
+        return 0;
+    }
 }

+ 33 - 25
jme3-desktop/src/main/java/com/jme3/system/awt/AwtPanelsContext.java

@@ -109,9 +109,8 @@ public class AwtPanelsContext implements JmeContext {
         }
     }
 
-    public void setInputSource(AwtPanel panel){
-        if (!panels.contains(panel))
-            throw new IllegalArgumentException();
+    public void setInputSource(AwtPanel panel) {
+        if (!panels.contains(panel)) throw new IllegalArgumentException();
 
         inputSource = panel;
         mouseInput.setInputSource(panel);
@@ -187,42 +186,41 @@ public class AwtPanelsContext implements JmeContext {
     public Context getOpenCLContext() {
         return actualContext.getOpenCLContext();
     }
-    
-    public AwtPanelsContext(){
-    }
 
-    public AwtPanel createPanel(PaintMode paintMode){
+    public AwtPanelsContext() {}
+
+    public AwtPanel createPanel(PaintMode paintMode) {
         AwtPanel panel = new AwtPanel(paintMode);
         panels.add(panel);
         return panel;
     }
-    
-    public AwtPanel createPanel(PaintMode paintMode, boolean srgb){
+
+    public AwtPanel createPanel(PaintMode paintMode, boolean srgb) {
         AwtPanel panel = new AwtPanel(paintMode, srgb);
         panels.add(panel);
         return panel;
     }
 
-    private void initInThread(){
+    private void initInThread() {
         listener.initialize();
     }
 
-    private void updateInThread(){
+    private void updateInThread() {
         // Check if throttle required
         boolean needThrottle = true;
 
-        for (AwtPanel panel : panels){
-            if (panel.isActiveDrawing()){
+        for (AwtPanel panel : panels) {
+            if (panel.isActiveDrawing()) {
                 needThrottle = false;
                 break;
             }
         }
 
-        if (lastThrottleState != needThrottle){
+        if (lastThrottleState != needThrottle) {
             lastThrottleState = needThrottle;
-            if (lastThrottleState){
+            if (lastThrottleState) {
                 System.out.println("OGL: Throttling update loop.");
-            }else{
+            } else {
                 System.out.println("OGL: Ceased throttling update loop.");
             }
         }
@@ -230,18 +228,17 @@ public class AwtPanelsContext implements JmeContext {
         if (needThrottle) {
             try {
                 Thread.sleep(100);
-            } catch (InterruptedException ex) {
-            }
+            } catch (InterruptedException ex) {}
         }
 
         listener.update();
-        
-        for (AwtPanel panel : panels){
+
+        for (AwtPanel panel : panels) {
             panel.onFrameEnd();
         }
     }
 
-    private void destroyInThread(){
+    private void destroyInThread() {
         listener.destroy();
     }
 
@@ -249,14 +246,14 @@ public class AwtPanelsContext implements JmeContext {
     public void setSettings(AppSettings settings) {
         this.settings.copyFrom(settings);
         this.settings.setRenderer(AppSettings.LWJGL_OPENGL2);
-        if (actualContext != null){
+        if (actualContext != null) {
             actualContext.setSettings(settings);
         }
     }
 
     @Override
     public void create(boolean waitFor) {
-        if (actualContext != null){
+        if (actualContext != null) {
             throw new IllegalStateException("Already created");
         }
 
@@ -267,8 +264,7 @@ public class AwtPanelsContext implements JmeContext {
 
     @Override
     public void destroy(boolean waitFor) {
-        if (actualContext == null)
-            throw new IllegalStateException("Not created");
+        if (actualContext == null) throw new IllegalStateException("Not created");
 
         // destroy parent context
         actualContext.destroy(waitFor);
@@ -328,4 +324,16 @@ public class AwtPanelsContext implements JmeContext {
     public int getWindowYPosition() {
         return inputSource.getY();
     }
+
+    @Override
+    public Displays getDisplays() {
+        // TODO Auto-generated method stub
+        return null;
+    }
+
+    @Override
+    public int getPrimaryDisplay() {
+        // TODO Auto-generated method stub
+        return 0;
+    }
 }

+ 244 - 0
jme3-examples/src/main/java/jme3test/app/TestMonitorApp.java

@@ -0,0 +1,244 @@
+/*
+ * Copyright (c) 2009-2023 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package jme3test.app;
+
+import com.jme3.app.SimpleApplication;
+import com.jme3.font.BitmapText;
+import com.jme3.input.KeyInput;
+import com.jme3.input.controls.ActionListener;
+import com.jme3.input.controls.KeyTrigger;
+import com.jme3.material.Material;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.shape.Box;
+import com.jme3.system.AppSettings;
+import com.jme3.system.DisplayInfo;
+import com.jme3.system.Displays;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Tests the capability to change which monitor the window will be created on.
+ * Also, shows that you can force JME to center the window. Also, it shows to to
+ * force JME to set the window to x,y coords. Center window and window position
+ * doesn't apply if in fullscreen.
+ *
+ * @author Kevin Bales
+ */
+public class TestMonitorApp extends SimpleApplication implements ActionListener {
+
+    private BitmapText txt;
+    private BitmapText selectedMonitorTxt;
+    private BitmapText fullScreenTxt;
+    private int monitorSelected = 0;
+    private Displays monitors = null;
+
+    public static void main(String[] args) {
+        TestMonitorApp app = new TestMonitorApp();
+        AppSettings settings = new AppSettings(true);
+        settings.setResizable(false);
+        app.setShowSettings(true);
+        settings.setRenderer(AppSettings.LWJGL_OPENGL33);
+        settings.setDisplay(0);
+        settings.setResolution(800, 600);
+
+        settings.setFullscreen(true);
+
+        // Force JME to center the window, this only applies if it is
+        // not fullscreen.
+        settings.setCenterWindow(true);
+
+        // If center window is not turned on, you can force JME to
+        // open the window at certain x,y coords. These are ignored
+        // if the screen is set to "fullscreen".
+        settings.setWindowXPosition(0);
+        settings.setWindowYPosition(0);
+
+        try {
+            // Let's try and load the AppSetting parameters back into memory
+            InputStream out = new FileInputStream("TestMonitorApp.prefs");
+            settings.load(out);
+        } catch (IOException e) {
+            System.out.println("failed to load settings, reverting to defaults");
+        }
+        app.setSettings(settings);
+
+        app.start();
+    }
+
+    @Override
+    public void simpleInitApp() {
+        flyCam.setDragToRotate(true);
+        int numMonitors = 1;
+
+        // If monitor is define, Jme supports multiple monitors. Setup to keys
+        if (monitors == null) {
+            inputManager.addMapping("down", new KeyTrigger(KeyInput.KEY_DOWN));
+            inputManager.addMapping("fullscreen", new KeyTrigger(KeyInput.KEY_F));
+            inputManager.addListener(this, "down", "fullscreen");
+        }
+
+        // Get the selected monitor
+        monitorSelected = settings.getDisplay();
+        monitors = context.getDisplays();
+        if (monitors != null) numMonitors = monitors.size();
+
+        // Let's define the labels for users to see what is going on with Multiple
+        // Monitor
+        String labelValue = "";
+        labelValue = "There are " + numMonitors + " monitor(s) hooked up to this computer.";
+        txt = new BitmapText(loadGuiFont());
+        txt.setText(labelValue);
+        txt.setLocalTranslation(0, settings.getHeight(), 0);
+        guiNode.attachChild(txt);
+
+        txt = new BitmapText(loadGuiFont());
+        if (!settings.isFullscreen()) txt.setText(
+            "Window is on Monitor N/A (fullscreen only feature)"
+        ); else txt.setText("Window is on Monitor " + settings.getDisplay());
+
+        txt.setLocalTranslation(0, settings.getHeight() - 40, 0);
+        guiNode.attachChild(txt);
+
+        if (monitors != null) {
+            selectedMonitorTxt = new BitmapText(loadGuiFont());
+            // Lets display information about selected monitor
+            String label =
+                "Selected Monitor " +
+                "Name: " +
+                monitors.get(settings.getDisplay()).name +
+                " " +
+                monitorSelected +
+                " Res: " +
+                monitors.get(settings.getDisplay()).width +
+                "," +
+                monitors.get(settings.getDisplay()).height +
+                " refresh: " +
+                monitors.get(settings.getDisplay()).rate;
+            selectedMonitorTxt.setText(label);
+            selectedMonitorTxt.setLocalTranslation(0, settings.getHeight() - 80, 0);
+            guiNode.attachChild(selectedMonitorTxt);
+
+            // Let's loop through all the monitors and display on the screen
+            for (int i = 0; i < monitors.size(); i++) {
+                DisplayInfo monitor = monitors.get(i);
+                labelValue =
+                    "Mon : " +
+                    i +
+                    " " +
+                    monitor.name +
+                    " " +
+                    monitor.width +
+                    "," +
+                    monitor.height +
+                    " refresh: " +
+                    monitor.rate;
+                txt = new BitmapText(loadGuiFont());
+                txt.setText(labelValue);
+                txt.setLocalTranslation(0, settings.getHeight() - 160 - (40 * i), 0);
+                guiNode.attachChild(txt);
+            }
+        }
+
+        // Lets put a label up there for FullScreen/Window toggle
+        fullScreenTxt = new BitmapText(loadGuiFont());
+        if (!settings.isFullscreen()) fullScreenTxt.setText("(f) Window Screen"); else fullScreenTxt.setText(
+            "(f) Fullscreen"
+        );
+
+        fullScreenTxt.setLocalTranslation(00, settings.getHeight() - 240, 0);
+        guiNode.attachChild(fullScreenTxt);
+
+        BitmapText infoTxt = new BitmapText(loadGuiFont());
+        infoTxt.setText("Restart is required to activate changes in settings.");
+        infoTxt.setLocalTranslation(0, settings.getHeight() - 300, 0);
+        guiNode.attachChild(infoTxt);
+    }
+
+    @Override
+    public void onAction(String name, boolean isPressed, float tpf) {
+        if (monitors == null) return;
+
+        if (name.equals("down") && isPressed) {
+            monitorSelected++;
+            if (monitorSelected >= monitors.size()) monitorSelected = 0;
+            saveSettings();
+        } else if (name.equals("up") && isPressed) {
+            monitorSelected--;
+            if (monitorSelected < 0) monitorSelected = monitors.size() - 1;
+            saveSettings();
+        } else if (name.equals("fullscreen") && isPressed) {
+            settings.setFullscreen(!settings.isFullscreen());
+            saveSettings();
+        }
+    }
+
+    /**
+     * This function saves out the AppSettings into a file to be loaded back in
+     * on start of application.
+     */
+    public void saveSettings() {
+        try {
+            settings.setDisplay(monitorSelected);
+            OutputStream out = new FileOutputStream("TestMonitorApp.prefs");
+            settings.save(out);
+
+            int monitorSelected = settings.getDisplay();
+            String label =
+                "Selected Monitor " +
+                monitorSelected +
+                " " +
+                monitors.get(monitorSelected).name +
+                " Res: " +
+                monitors.get(monitorSelected).width +
+                "," +
+                monitors.get(monitorSelected).height +
+                "refresh: " +
+                monitors.get(monitorSelected).rate;
+            selectedMonitorTxt.setText(label);
+            if (!settings.isFullscreen()) fullScreenTxt.setText(
+                "(f) Window Screen"
+            ); else fullScreenTxt.setText("(f) Fullscreen");
+        } catch (FileNotFoundException e) {
+            // TODO Auto-generated catch block
+            e.printStackTrace();
+        } catch (IOException e) {
+            // TODO Auto-generated catch block
+            e.printStackTrace();
+        }
+    }
+}

+ 5 - 3
jme3-examples/src/main/java/jme3test/app/TestResizableApp.java

@@ -61,9 +61,11 @@ public class TestResizableApp extends SimpleApplication {
         super.reshape(width, height);
 
         // Need to move text relative to app height
-        txt.setLocalTranslation(0, settings.getHeight(), 0);
-        txt.setText("Drag the corners of the application to resize it.\n" +
-                    "Current Size: " + settings.getWidth() + "x" + settings.getHeight());
+        if (txt != null) {
+           txt.setLocalTranslation(0, settings.getHeight(), 0);
+           txt.setText("Drag the corners of the application to resize it.\n" +
+                       "Current Size: " + settings.getWidth() + "x" + settings.getHeight());
+        }
     }
     
     @Override

+ 3 - 3
jme3-examples/src/main/java/jme3test/batching/TestBatchNode.java

@@ -45,7 +45,7 @@ import com.jme3.scene.Spatial;
 import com.jme3.scene.debug.WireFrustum;
 import com.jme3.scene.shape.Box;
 import com.jme3.system.NanoTimer;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 /**
  * A test to demonstrate the usage and functionality of the {@link BatchNode}
@@ -96,8 +96,8 @@ public class TestBatchNode extends SimpleApplication {
         cube2 = new Geometry("cube2", box);
         cube2.setMaterial(mat);
 
-        TangentBinormalGenerator.generate(cube);
-        TangentBinormalGenerator.generate(cube2);
+        MikktspaceTangentGenerator.generate(cube);
+        MikktspaceTangentGenerator.generate(cube2);
 
         batch.attachChild(cube);
         //  batch.attachChild(cube2);

+ 2 - 2
jme3-examples/src/main/java/jme3test/effect/TestEverything.java

@@ -48,7 +48,7 @@ import com.jme3.scene.shape.Box;
 import com.jme3.shadow.DirectionalLightShadowRenderer;
 import com.jme3.texture.Texture;
 import com.jme3.util.SkyFactory;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestEverything extends SimpleApplication {
 
@@ -122,7 +122,7 @@ public class TestEverything extends SimpleApplication {
     public void setupFloor(){
         Material mat = assetManager.loadMaterial("Textures/Terrain/BrickWall/BrickWall.j3m");
         Box floor = new Box(50, 1f, 50);
-        TangentBinormalGenerator.generate(floor);
+        MikktspaceTangentGenerator.generate(floor);
         floor.scaleTextureCoordinates(new Vector2f(5, 5));
         Geometry floorGeom = new Geometry("Floor", floor);
         floorGeom.setMaterial(mat);

+ 2 - 2
jme3-examples/src/main/java/jme3test/helloworld/HelloMaterial.java

@@ -43,7 +43,7 @@ import com.jme3.scene.Geometry;
 import com.jme3.scene.shape.Box;
 import com.jme3.scene.shape.Sphere;
 import com.jme3.texture.Texture;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 /** Sample 6 - how to give an object's surface a material and texture.
  * How to make objects transparent. How to make bumpy and shiny surfaces.  */
@@ -81,7 +81,7 @@ public class HelloMaterial extends SimpleApplication {
     Sphere sphereMesh = new Sphere(32,32, 2f);
     Geometry sphereGeo = new Geometry("Shiny rock", sphereMesh);
     sphereMesh.setTextureMode(Sphere.TextureMode.Projected); // better quality on spheres
-    TangentBinormalGenerator.generate(sphereMesh);           // for lighting effect
+    MikktspaceTangentGenerator.generate(sphereMesh); // for lighting effect
     Material sphereMat = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
     sphereMat.setTexture("DiffuseMap", assetManager.loadTexture("Textures/Terrain/Pond/Pond.jpg"));
     sphereMat.setTexture("NormalMap", assetManager.loadTexture("Textures/Terrain/Pond/Pond_normal.png"));

+ 3 - 3
jme3-examples/src/main/java/jme3test/light/TestDirectionalLightShadow.java

@@ -58,7 +58,7 @@ import com.jme3.texture.Texture;
 import com.jme3.texture.Texture.WrapMode;
 import com.jme3.util.SkyFactory;
 import com.jme3.util.SkyFactory.EnvMapType;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestDirectionalLightShadow extends SimpleApplication implements ActionListener, AnalogListener {
 
@@ -108,8 +108,8 @@ public class TestDirectionalLightShadow extends SimpleApplication implements Act
         obj[0].setShadowMode(ShadowMode.CastAndReceive);
         obj[1] = new Geometry("cube", new Box(1.0f, 1.0f, 1.0f));
         obj[1].setShadowMode(ShadowMode.CastAndReceive);
-        TangentBinormalGenerator.generate(obj[1]);
-        TangentBinormalGenerator.generate(obj[0]);
+        MikktspaceTangentGenerator.generate(obj[1]);
+        MikktspaceTangentGenerator.generate(obj[0]);
 
         Spatial t = obj[0].clone(false);
         t.setLocalScale(10f);

+ 2 - 2
jme3-examples/src/main/java/jme3test/light/TestShadowsPerf.java

@@ -48,7 +48,7 @@ import com.jme3.scene.shape.Box;
 import com.jme3.scene.shape.Sphere;
 import com.jme3.shadow.DirectionalLightShadowRenderer;
 import com.jme3.shadow.EdgeFilteringMode;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestShadowsPerf extends SimpleApplication {
 
@@ -84,7 +84,7 @@ public class TestShadowsPerf extends SimpleApplication {
         Sphere sphMesh = new Sphere(32, 32, 1);
         sphMesh.setTextureMode(Sphere.TextureMode.Projected);
         sphMesh.updateGeometry(32, 32, 1, false, false);
-        TangentBinormalGenerator.generate(sphMesh);
+        MikktspaceTangentGenerator.generate(sphMesh);
 
         sphere = new Geometry("Rock Ball", sphMesh);
         sphere.setLocalTranslation(0, 5, 0);

+ 2 - 2
jme3-examples/src/main/java/jme3test/light/TestSimpleLighting.java

@@ -43,7 +43,7 @@ import com.jme3.math.Vector3f;
 import com.jme3.scene.Geometry;
 import com.jme3.scene.shape.Sphere;
 import com.jme3.util.MaterialDebugAppState;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestSimpleLighting extends SimpleApplication {
 
@@ -59,7 +59,7 @@ public class TestSimpleLighting extends SimpleApplication {
     @Override
     public void simpleInitApp() {
         Geometry teapot = (Geometry) assetManager.loadModel("Models/Teapot/Teapot.obj");
-        TangentBinormalGenerator.generate(teapot.getMesh(), true);
+        MikktspaceTangentGenerator.generate(teapot.getMesh());
 
         teapot.setLocalScale(2f);
         Material mat = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");

+ 3 - 3
jme3-examples/src/main/java/jme3test/light/TestSpotLight.java

@@ -43,7 +43,7 @@ import com.jme3.scene.Spatial;
 import com.jme3.scene.shape.Box;
 import com.jme3.scene.shape.Sphere;
 import com.jme3.texture.Texture.WrapMode;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestSpotLight extends SimpleApplication {
 
@@ -101,7 +101,7 @@ public class TestSpotLight extends SimpleApplication {
         
         
         Box floor = new Box(50, 1f, 50);
-        TangentBinormalGenerator.generate(floor);
+        MikktspaceTangentGenerator.generate(floor);
         floor.scaleTextureCoordinates(new Vector2f(5, 5));
         Geometry floorGeom = new Geometry("Floor", floor);
         floorGeom.setMaterial(mat);
@@ -120,7 +120,7 @@ public class TestSpotLight extends SimpleApplication {
         signpost.setLocalTranslation(12, 3.5f, 30);
         signpost.setLocalScale(4);
         signpost.setShadowMode(ShadowMode.CastAndReceive);
-        TangentBinormalGenerator.generate(signpost);
+        MikktspaceTangentGenerator.generate(signpost);
         rootNode.attachChild(signpost);
     }
 

+ 3 - 3
jme3-examples/src/main/java/jme3test/light/TestSpotLightShadows.java

@@ -49,7 +49,7 @@ import com.jme3.shadow.EdgeFilteringMode;
 import com.jme3.shadow.SpotLightShadowFilter;
 import com.jme3.shadow.SpotLightShadowRenderer;
 import com.jme3.texture.Texture.WrapMode;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestSpotLightShadows extends SimpleApplication {
 
@@ -149,7 +149,7 @@ public class TestSpotLightShadows extends SimpleApplication {
 
 
         Box floor = new Box(50, 1f, 50);
-        TangentBinormalGenerator.generate(floor);
+        MikktspaceTangentGenerator.generate(floor);
         floor.scaleTextureCoordinates(new Vector2f(5, 5));
         Geometry floorGeom = new Geometry("Floor", floor);
         floorGeom.setMaterial(mat);
@@ -166,7 +166,7 @@ public class TestSpotLightShadows extends SimpleApplication {
         signpost.setLocalTranslation(12, 3.5f, 30);
         signpost.setLocalScale(4);
         signpost.setShadowMode(ShadowMode.CastAndReceive);
-        TangentBinormalGenerator.generate(signpost);
+        MikktspaceTangentGenerator.generate(signpost);
         rootNode.attachChild(signpost);
     }
 

+ 2 - 2
jme3-examples/src/main/java/jme3test/light/TestTangentCube.java

@@ -40,7 +40,7 @@ import com.jme3.math.FastMath;
 import com.jme3.math.Vector3f;
 import com.jme3.scene.Geometry;
 import com.jme3.scene.shape.Box;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 /**
  *
@@ -57,7 +57,7 @@ public class TestTangentCube extends SimpleApplication {
     public void simpleInitApp() {
         Box aBox = new Box(1, 1, 1);
         Geometry aGeometry = new Geometry("Box", aBox);
-        TangentBinormalGenerator.generate(aBox);
+        MikktspaceTangentGenerator.generate(aBox);
 
         Material aMaterial = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
         aMaterial.setTexture("DiffuseMap",

+ 5 - 3
jme3-examples/src/main/java/jme3test/light/TestTangentGen.java

@@ -45,7 +45,9 @@ import com.jme3.scene.VertexBuffer.Type;
 import com.jme3.scene.shape.Quad;
 import com.jme3.scene.shape.Sphere;
 import com.jme3.util.BufferUtils;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.TangentUtils;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
+
 import java.nio.FloatBuffer;
 import java.nio.IntBuffer;
 
@@ -79,7 +81,7 @@ public class TestTangentGen extends SimpleApplication {
     }
 
     private void addMesh(String name, Mesh mesh, Vector3f translation) {
-        TangentBinormalGenerator.generate(mesh);
+        MikktspaceTangentGenerator.generate(mesh);
 
         Geometry testGeom = new Geometry(name, mesh);
         Material mat = assetManager.loadMaterial("Textures/BumpMapTest/Tangent.j3m");
@@ -89,7 +91,7 @@ public class TestTangentGen extends SimpleApplication {
 
         Geometry debug = new Geometry(
                 "Debug " + name,
-                TangentBinormalGenerator.genTbnLines(mesh, 0.08f)
+                TangentUtils.genTbnLines(mesh, 0.08f)
         );
         Material debugMat = assetManager.loadMaterial("Common/Materials/VertexColor.j3m");
         debug.setMaterial(debugMat);

+ 4 - 3
jme3-examples/src/main/java/jme3test/light/TestTangentGenBadUV.java

@@ -42,7 +42,8 @@ import com.jme3.math.Vector3f;
 import com.jme3.scene.Geometry;
 import com.jme3.scene.Spatial;
 import com.jme3.scene.shape.Sphere;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.TangentUtils;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestTangentGenBadUV extends SimpleApplication {
 
@@ -60,7 +61,7 @@ public class TestTangentGenBadUV extends SimpleApplication {
         Spatial teapot = assetManager.loadModel("Models/Teapot/Teapot.obj");
         if (teapot instanceof Geometry){
             Geometry g = (Geometry) teapot;
-            TangentBinormalGenerator.generate(g.getMesh());
+            MikktspaceTangentGenerator.generate(g.getMesh());
         }else{
             throw new RuntimeException();
         }
@@ -71,7 +72,7 @@ public class TestTangentGenBadUV extends SimpleApplication {
 
         Geometry debug = new Geometry(
                 "Debug Teapot",
-                TangentBinormalGenerator.genTbnLines(((Geometry) teapot).getMesh(), 0.03f)
+                TangentUtils.genTbnLines(((Geometry) teapot).getMesh(), 0.03f)
         );
         Material debugMat = assetManager.loadMaterial("Common/Materials/VertexColor.j3m");
         debug.setMaterial(debugMat);

+ 2 - 1
jme3-examples/src/main/java/jme3test/light/TestTangentSpace.java

@@ -44,6 +44,7 @@ import com.jme3.scene.Geometry;
 import com.jme3.scene.Node;
 import com.jme3.scene.Spatial;
 import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.TangentUtils;
 import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 /**
@@ -120,7 +121,7 @@ public class TestTangentSpace extends SimpleApplication {
     private void createDebugTangents(Geometry geom) {
         Geometry debug = new Geometry(
                 "Debug " + geom.getName(),
-                TangentBinormalGenerator.genTbnLines(geom.getMesh(), 0.8f)
+                TangentUtils.genTbnLines(geom.getMesh(), 0.8f)
         );
         Material debugMat = assetManager.loadMaterial("Common/Materials/VertexColor.j3m");
         debug.setMaterial(debugMat);

+ 2 - 2
jme3-examples/src/main/java/jme3test/light/TestTransparentShadow.java

@@ -50,7 +50,7 @@ import com.jme3.scene.shape.Sphere;
 import com.jme3.shadow.CompareMode;
 import com.jme3.shadow.DirectionalLightShadowRenderer;
 import com.jme3.shadow.EdgeFilteringMode;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestTransparentShadow extends SimpleApplication {
 
@@ -72,7 +72,7 @@ public class TestTransparentShadow extends SimpleApplication {
                 new Vector3f(-10, 0, -10)
         );
         rm.scaleTextureCoordinates(Vector2f.UNIT_XY.mult(10));
-        TangentBinormalGenerator.generate(rm);
+        MikktspaceTangentGenerator.generate(rm);
 
         Geometry geom = new Geometry("floor", rm);
         Material mat = assetManager.loadMaterial("Textures/Terrain/Pond/Pond.j3m");

+ 2 - 2
jme3-examples/src/main/java/jme3test/light/TestTwoSideLighting.java

@@ -44,7 +44,7 @@ import com.jme3.math.Vector3f;
 import com.jme3.scene.Geometry;
 import com.jme3.scene.shape.Quad;
 import com.jme3.scene.shape.Sphere;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 /**
  * Checks two-sided lighting capability.
@@ -80,7 +80,7 @@ public class TestTwoSideLighting extends SimpleApplication {
         
         quadGeom.setMaterial(mat1);
         // SimpleBump material requires tangents.
-        TangentBinormalGenerator.generate(quadGeom);
+        MikktspaceTangentGenerator.generate(quadGeom);
         rootNode.attachChild(quadGeom);
         
         Geometry teapot = (Geometry) assetManager.loadModel("Models/Teapot/Teapot.obj");

+ 2 - 2
jme3-examples/src/main/java/jme3test/material/TestBumpModel.java

@@ -42,7 +42,7 @@ import com.jme3.scene.Geometry;
 import com.jme3.scene.Spatial;
 import com.jme3.scene.plugins.ogre.OgreMeshKey;
 import com.jme3.scene.shape.Sphere;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestBumpModel extends SimpleApplication {
 
@@ -59,7 +59,7 @@ public class TestBumpModel extends SimpleApplication {
     public void simpleInitApp() {
         Spatial signpost = assetManager.loadAsset(new OgreMeshKey("Models/Sign Post/Sign Post.mesh.xml"));
         signpost.setMaterial(assetManager.loadMaterial("Models/Sign Post/Sign Post.j3m"));
-        TangentBinormalGenerator.generate(signpost);
+        MikktspaceTangentGenerator.generate(signpost);
         rootNode.attachChild(signpost);
 
         lightMdl = new Geometry("Light", new Sphere(10, 10, 0.1f));

+ 2 - 2
jme3-examples/src/main/java/jme3test/material/TestNormalMapping.java

@@ -41,7 +41,7 @@ import com.jme3.math.Vector3f;
 import com.jme3.scene.Geometry;
 import com.jme3.scene.Spatial;
 import com.jme3.scene.shape.Sphere;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestNormalMapping extends SimpleApplication {
 
@@ -59,7 +59,7 @@ public class TestNormalMapping extends SimpleApplication {
         Sphere sphMesh = new Sphere(32, 32, 1);
         sphMesh.setTextureMode(Sphere.TextureMode.Projected);
         sphMesh.updateGeometry(32, 32, 1, false, false);
-        TangentBinormalGenerator.generate(sphMesh);
+        MikktspaceTangentGenerator.generate(sphMesh);
 
         Geometry sphere = new Geometry("Rock Ball", sphMesh);
         Material mat = assetManager.loadMaterial("Textures/Terrain/Pond/Pond.j3m");

+ 3 - 3
jme3-examples/src/main/java/jme3test/material/TestParallax.java

@@ -44,7 +44,7 @@ import com.jme3.scene.Geometry;
 import com.jme3.scene.Spatial;
 import com.jme3.scene.shape.RectangleMesh;
 import com.jme3.util.SkyFactory;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestParallax extends SimpleApplication {
 
@@ -77,7 +77,7 @@ public class TestParallax extends SimpleApplication {
         rm.scaleTextureCoordinates(new Vector2f(10, 10));
 
         Geometry floorGeom = new Geometry("floorGeom", rm);
-        TangentBinormalGenerator.generate(floorGeom);
+        MikktspaceTangentGenerator.generate(floorGeom);
         floorGeom.setMaterial(mat);
 
         rootNode.attachChild(floorGeom);
@@ -86,7 +86,7 @@ public class TestParallax extends SimpleApplication {
     public void setupSignpost() {
         Spatial signpost = assetManager.loadModel("Models/Sign Post/Sign Post.mesh.xml");
         Material matSp = assetManager.loadMaterial("Models/Sign Post/Sign Post.j3m");
-        TangentBinormalGenerator.generate(signpost);
+        MikktspaceTangentGenerator.generate(signpost);
         signpost.setMaterial(matSp);
         signpost.rotate(0, FastMath.HALF_PI, 0);
         signpost.setLocalTranslation(12, 23.5f, 30);

+ 3 - 3
jme3-examples/src/main/java/jme3test/material/TestParallaxPBR.java

@@ -44,7 +44,7 @@ import com.jme3.scene.Geometry;
 import com.jme3.scene.Spatial;
 import com.jme3.scene.shape.RectangleMesh;
 import com.jme3.util.SkyFactory;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestParallaxPBR extends SimpleApplication {
 
@@ -80,7 +80,7 @@ public class TestParallaxPBR extends SimpleApplication {
         rm.scaleTextureCoordinates(new Vector2f(10, 10));
 
         Geometry floorGeom = new Geometry("floorGeom", rm);
-        TangentBinormalGenerator.generate(floorGeom);
+        MikktspaceTangentGenerator.generate(floorGeom);
         //floorGeom.setLocalScale(100);
 
         floorGeom.setMaterial(mat);
@@ -90,7 +90,7 @@ public class TestParallaxPBR extends SimpleApplication {
     public void setupSignpost() {
         Spatial signpost = assetManager.loadModel("Models/Sign Post/Sign Post.mesh.xml");
         Material mat = assetManager.loadMaterial("Models/Sign Post/Sign Post.j3m");
-        TangentBinormalGenerator.generate(signpost);
+        MikktspaceTangentGenerator.generate(signpost);
         signpost.setMaterial(mat);
         signpost.rotate(0, FastMath.HALF_PI, 0);
         signpost.setLocalTranslation(12, 23.5f, 30);

+ 2 - 2
jme3-examples/src/main/java/jme3test/material/TestSimpleBumps.java

@@ -42,7 +42,7 @@ import com.jme3.scene.Geometry;
 import com.jme3.scene.Spatial;
 import com.jme3.scene.shape.Quad;
 import com.jme3.scene.shape.Sphere;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 // phong cutoff for light to normal angle > 90?
 public class TestSimpleBumps extends SimpleApplication {
@@ -63,7 +63,7 @@ public class TestSimpleBumps extends SimpleApplication {
         Geometry sphere = new Geometry("Rock Ball", quadMesh);
         Material mat = assetManager.loadMaterial("Textures/BumpMapTest/SimpleBump.j3m");
         sphere.setMaterial(mat);
-        TangentBinormalGenerator.generate(sphere);
+        MikktspaceTangentGenerator.generate(sphere);
         rootNode.attachChild(sphere);
 
         lightMdl = new Geometry("Light", new Sphere(10, 10, 0.1f));

+ 2 - 2
jme3-examples/src/main/java/jme3test/material/TestUnshadedModel.java

@@ -8,7 +8,7 @@ import com.jme3.math.ColorRGBA;
 import com.jme3.math.Vector3f;
 import com.jme3.scene.Geometry;
 import com.jme3.scene.shape.Sphere;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestUnshadedModel extends SimpleApplication {
 
@@ -22,7 +22,7 @@ public class TestUnshadedModel extends SimpleApplication {
         Sphere sphMesh = new Sphere(32, 32, 1);
         sphMesh.setTextureMode(Sphere.TextureMode.Projected);
         sphMesh.updateGeometry(32, 32, 1, false, false);
-        TangentBinormalGenerator.generate(sphMesh);
+        MikktspaceTangentGenerator.generate(sphMesh);
 
         Geometry sphere = new Geometry("Rock Ball", sphMesh);
         Material mat = assetManager.loadMaterial("Textures/Terrain/Pond/Pond.j3m");

+ 2 - 2
jme3-examples/src/main/java/jme3test/model/anim/TestAnimationFactory.java

@@ -12,7 +12,7 @@ import com.jme3.math.Vector3f;
 import com.jme3.scene.Geometry;
 import com.jme3.scene.Node;
 import com.jme3.scene.shape.Box;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestAnimationFactory extends SimpleApplication {
 
@@ -45,7 +45,7 @@ public class TestAnimationFactory extends SimpleApplication {
         childModel.setLocalTranslation(2, 2, 2);
         childModel.attachChild(childGeom);
         model.attachChild(childModel);
-        TangentBinormalGenerator.generate(model);
+        MikktspaceTangentGenerator.generate(model);
 
         // Construct a complex animation using AnimFactory:
         // 6 seconds in duration, named "anim", running at 25 frames per second

+ 2 - 2
jme3-examples/src/main/java/jme3test/post/TestLightScattering.java

@@ -45,7 +45,7 @@ import com.jme3.scene.Geometry;
 import com.jme3.scene.Node;
 import com.jme3.scene.Spatial;
 import com.jme3.util.SkyFactory;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestLightScattering extends SimpleApplication {
 
@@ -66,7 +66,7 @@ public class TestLightScattering extends SimpleApplication {
         flyCam.setMoveSpeed(10);
         Material mat = assetManager.loadMaterial("Textures/Terrain/Rocky/Rocky.j3m");
         Spatial scene = assetManager.loadModel("Models/Terrain/Terrain.mesh.xml");
-        TangentBinormalGenerator.generate(((Geometry)((Node)scene).getChild(0)).getMesh());
+        MikktspaceTangentGenerator.generate(((Geometry) ((Node) scene).getChild(0)).getMesh());
         scene.setMaterial(mat);
         scene.setShadowMode(ShadowMode.CastAndReceive);
         scene.setLocalScale(400);

+ 2 - 2
jme3-examples/src/main/java/jme3test/post/TestPostFilters.java

@@ -48,7 +48,7 @@ import com.jme3.scene.shape.Box;
 import com.jme3.texture.Texture;
 import com.jme3.util.SkyFactory;
 import com.jme3.util.SkyFactory.EnvMapType;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestPostFilters extends SimpleApplication implements ActionListener {
 
@@ -111,7 +111,7 @@ public class TestPostFilters extends SimpleApplication implements ActionListener
     public void setupFloor() {
         Material mat = assetManager.loadMaterial("Textures/Terrain/BrickWall/BrickWall.j3m");
         Box floor = new Box(50, 1f, 50);
-        TangentBinormalGenerator.generate(floor);
+        MikktspaceTangentGenerator.generate(floor);
         floor.scaleTextureCoordinates(new Vector2f(5, 5));
         Geometry floorGeom = new Geometry("Floor", floor);
         floorGeom.setMaterial(mat);

+ 2 - 2
jme3-examples/src/main/java/jme3test/post/TestTransparentSSAO.java

@@ -12,7 +12,7 @@ import com.jme3.renderer.queue.RenderQueue.ShadowMode;
 import com.jme3.scene.Geometry;
 import com.jme3.scene.Spatial;
 import com.jme3.scene.shape.RectangleMesh;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.mikktspace.MikktspaceTangentGenerator;
 
 public class TestTransparentSSAO extends SimpleApplication {
 
@@ -42,7 +42,7 @@ public class TestTransparentSSAO extends SimpleApplication {
         geom.setMaterial(mat);
 
         geom.setShadowMode(ShadowMode.Receive);
-        TangentBinormalGenerator.generate(geom);
+        MikktspaceTangentGenerator.generate(geom);
         rootNode.attachChild(geom);
 
         // create the geometry and attach it

+ 26 - 18
jme3-ios/src/main/java/com/jme3/system/ios/IGLESContext.java

@@ -42,7 +42,6 @@ import com.jme3.opencl.Context;
 import com.jme3.renderer.ios.IosGL;
 import com.jme3.renderer.opengl.*;
 import com.jme3.system.*;
-
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.logging.Level;
 import java.util.logging.Logger;
@@ -63,10 +62,10 @@ public class IGLESContext implements JmeContext {
     protected Timer timer;
     protected SystemListener listener;
     protected IosInputHandler input;
-    protected int minFrameDuration = 0;                   // No FPS cap
+    protected int minFrameDuration = 0; // No FPS cap
 
     public IGLESContext() {
-           logger.log(Level.FINE, "IGLESContext constructor");
+        logger.log(Level.FINE, "IGLESContext constructor");
     }
 
     @Override
@@ -123,13 +122,13 @@ public class IGLESContext implements JmeContext {
 
     @Override
     public JoyInput getJoyInput() {
-    /*
+        /*
         if (androidSensorJoyInput == null) {
             androidSensorJoyInput = new AndroidSensorJoyInput();
         }
         return androidSensorJoyInput;
         */
-        return null;//  new DummySensorJoyInput();
+        return null; //  new DummySensorJoyInput();
     }
 
     @Override
@@ -143,8 +142,7 @@ public class IGLESContext implements JmeContext {
     }
 
     @Override
-    public void setTitle(String title) {
-    }
+    public void setTitle(String title) {}
 
     @Override
     public boolean isCreated() {
@@ -160,7 +158,7 @@ public class IGLESContext implements JmeContext {
     @Override
     public boolean isRenderable() {
         logger.log(Level.FINE, "IGLESContext isRenderable");
-        return true;// renderable.get();
+        return true; // renderable.get();
     }
 
     @Override
@@ -169,18 +167,18 @@ public class IGLESContext implements JmeContext {
         IosGL gl = new IosGL();
 
         if (settings.getBoolean("GraphicsDebug")) {
-            gl = (IosGL)GLDebug.createProxy(gl, gl, GL.class, GLExt.class, GLFbo.class);
+            gl = (IosGL) GLDebug.createProxy(gl, gl, GL.class, GLExt.class, GLFbo.class);
         }
 
         renderer = new GLRenderer(gl, gl, gl);
         renderer.initialize();
-        
+
         input = new IosInputHandler();
         timer = new NanoTimer();
 
-//synchronized (createdLock){
-            created.set(true);
-            //createdLock.notifyAll();
+        //synchronized (createdLock){
+        created.set(true);
+        //createdLock.notifyAll();
         //}
 
         listener.initialize();
@@ -196,8 +194,7 @@ public class IGLESContext implements JmeContext {
     }
 
     @Override
-    public void restart() {
-    }
+    public void restart() {}
 
     @Override
     public void destroy(boolean waitFor) {
@@ -217,8 +214,7 @@ public class IGLESContext implements JmeContext {
         while (renderable.get() != createdVal) {
             try {
                 Thread.sleep(10);
-            } catch (InterruptedException ex) {
-            }
+            } catch (InterruptedException ex) {}
         }
     }
 
@@ -267,4 +263,16 @@ public class IGLESContext implements JmeContext {
     public int getWindowYPosition() {
         throw new UnsupportedOperationException("not implemented yet");
     }
-}
+
+    @Override
+    public Displays getDisplays() {
+        // TODO Auto-generated method stub
+        return null;
+    }
+
+    @Override
+    public int getPrimaryDisplay() {
+        // TODO Auto-generated method stub
+        return 0;
+    }
+}

+ 519 - 505
jme3-lwjgl/src/main/java/com/jme3/system/lwjgl/LwjglCanvas.java

@@ -1,505 +1,519 @@
-/*
- * Copyright (c) 2009-2021 jMonkeyEngine
- * All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are
- * met:
- *
- * * Redistributions of source code must retain the above copyright
- *   notice, this list of conditions and the following disclaimer.
- *
- * * Redistributions in binary form must reproduce the above copyright
- *   notice, this list of conditions and the following disclaimer in the
- *   documentation and/or other materials provided with the distribution.
- *
- * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
- *   may be used to endorse or promote products derived from this software
- *   without specific prior written permission.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
- * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
- * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
- * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
- * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
- * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
- * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
- * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
- * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
- * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
- * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- */
-
-package com.jme3.system.lwjgl;
-
-import com.jme3.system.AppSettings;
-import com.jme3.system.JmeCanvasContext;
-import com.jme3.system.JmeContext.Type;
-import com.jme3.system.JmeSystem;
-import com.jme3.system.Platform;
-import java.awt.Canvas;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-import javax.swing.SwingUtilities;
-import org.lwjgl.LWJGLException;
-import org.lwjgl.input.Keyboard;
-import org.lwjgl.input.Mouse;
-import org.lwjgl.opengl.Display;
-import org.lwjgl.opengl.Pbuffer;
-import org.lwjgl.opengl.PixelFormat;
-
-public class LwjglCanvas extends LwjglAbstractDisplay implements JmeCanvasContext {
-
-    protected static final int TASK_NOTHING = 0,
-                               TASK_DESTROY_DISPLAY = 1,
-                               TASK_CREATE_DISPLAY = 2,
-                               TASK_COMPLETE = 3;
-    
-//    protected static final boolean USE_SHARED_CONTEXT =
-//                Boolean.parseBoolean(System.getProperty("jme3.canvas.sharedctx", "true"));
-    
-    protected static final boolean USE_SHARED_CONTEXT = false;
-    
-    private static final Logger logger = Logger.getLogger(LwjglDisplay.class.getName());
-    private Canvas canvas;
-    private int width;
-    private int height;
-
-    private final Object taskLock = new Object();
-    private int desiredTask = TASK_NOTHING;
-
-    private Thread renderThread;
-    private boolean runningFirstTime = true;
-    private boolean mouseWasGrabbed = false;
-    
-    private boolean mouseWasCreated = false;
-    private boolean keyboardWasCreated = false;
-
-    private Pbuffer pbuffer;
-    private PixelFormat pbufferFormat;
-    private PixelFormat canvasFormat;
-
-    private class GLCanvas extends Canvas {
-        @Override
-        public void addNotify(){
-            super.addNotify();
-
-            if (renderThread != null && renderThread.getState() == Thread.State.TERMINATED) {
-                return; // already destroyed.
-            }
-
-            if (renderThread == null){
-                logger.log(Level.FINE, "EDT: Creating OGL thread.");
-
-                // Also set some settings on the canvas here.
-                // So we don't do it outside the AWT thread.
-                canvas.setFocusable(true);
-                canvas.setIgnoreRepaint(true);
-
-                renderThread = new Thread(LwjglCanvas.this, THREAD_NAME);
-                renderThread.start();
-            }else if (needClose.get()){
-                return;
-            }
-
-            logger.log(Level.FINE, "EDT: Telling OGL to create display ..");
-            synchronized (taskLock){
-                desiredTask = TASK_CREATE_DISPLAY;
-//                while (desiredTask != TASK_COMPLETE){
-//                    try {
-//                        taskLock.wait();
-//                    } catch (InterruptedException ex) {
-//                        return;
-//                    }
-//                }
-//                desiredTask = TASK_NOTHING;
-            }
-//            logger.log(Level.FINE, "EDT: OGL has created the display");
-        }
-
-        @Override
-        public void removeNotify(){
-            if (needClose.get()){
-                logger.log(Level.FINE, "EDT: Application is stopped. Not restoring canvas.");
-                super.removeNotify();
-                return;
-            }
-
-            // We must tell GL context to shut down and wait for it to
-            // shut down. Otherwise, issues will occur.
-            logger.log(Level.FINE, "EDT: Telling OGL to destroy display ..");
-            synchronized (taskLock){
-                desiredTask = TASK_DESTROY_DISPLAY;
-                while (desiredTask != TASK_COMPLETE){
-                    try {
-                        taskLock.wait();
-                    } catch (InterruptedException ex){
-                        super.removeNotify();
-                        return;
-                    }
-                }
-                desiredTask = TASK_NOTHING;
-            }
-            
-            logger.log(Level.FINE, "EDT: Acknowledged receipt of canvas death");
-            // GL context is dead at this point
-
-            super.removeNotify();
-        }
-    }
-
-    public LwjglCanvas(){
-        super();
-        canvas = new GLCanvas();
-    }
-
-    @Override
-    public Type getType() {
-        return Type.Canvas;
-    }
-
-    @Override
-    public void create(boolean waitFor){
-        if (renderThread == null){
-            logger.log(Level.FINE, "MAIN: Creating OGL thread.");
-
-            renderThread = new Thread(LwjglCanvas.this, THREAD_NAME);
-            renderThread.start();
-        }
-        // do not do anything.
-        // superclass's create() will be called at initInThread()
-        if (waitFor) {
-            waitFor(true);
-        }
-    }
-
-    @Override
-    public void setTitle(String title) {
-    }
-
-    @Override
-    public void restart() {
-        frameRate = settings.getFrameRate();
-        // TODO: Handle other cases, like change of pixel format, etc.
-    }
-
-    @Override
-    public Canvas getCanvas(){
-        return canvas;
-    }
-    
-    @Override
-    protected void runLoop(){
-        if (desiredTask != TASK_NOTHING){
-            synchronized (taskLock){
-                switch (desiredTask){
-                    case TASK_CREATE_DISPLAY:
-                        logger.log(Level.FINE, "OGL: Creating display ..");
-                        restoreCanvas();
-                        listener.gainFocus();
-                        desiredTask = TASK_NOTHING;
-                        break;
-                    case TASK_DESTROY_DISPLAY:
-                        logger.log(Level.FINE, "OGL: Destroying display ..");
-                        listener.loseFocus();
-                        pauseCanvas();
-                        break;
-                }
-                desiredTask = TASK_COMPLETE;
-                taskLock.notifyAll();
-            }
-        }
-        
-        if (renderable.get()){
-            int newWidth = Math.max(canvas.getWidth(), 1);
-            int newHeight = Math.max(canvas.getHeight(), 1);
-            if (width != newWidth || height != newHeight){
-                width = newWidth;
-                height = newHeight;
-                if (listener != null){
-                    listener.reshape(width, height);
-                }
-            }
-        }else{
-            if (frameRate <= 0){
-                // NOTE: MUST be done otherwise 
-                // Windows OS will freeze
-                Display.sync(30);
-            }
-        }
-        
-        super.runLoop();
-    }
-
-    private void pauseCanvas(){
-        if (Mouse.isCreated()){
-            if (Mouse.isGrabbed()){
-                Mouse.setGrabbed(false);
-                mouseWasGrabbed = true;
-            }
-            mouseWasCreated = true;
-            Mouse.destroy();
-        }
-        if (Keyboard.isCreated()){
-            keyboardWasCreated = true;
-            Keyboard.destroy();
-        }
-
-        renderable.set(false);
-        destroyContext();
-    }
-
-    /**
-     * Called to restore the canvas.
-     */
-    private void restoreCanvas(){
-        logger.log(Level.FINE, "OGL: Waiting for canvas to become displayable..");
-        while (!canvas.isDisplayable()){
-            try {
-                Thread.sleep(10);
-            } catch (InterruptedException ex) {
-                logger.log(Level.SEVERE, "OGL: Interrupted! ", ex);
-            }
-        }
-        
-        logger.log(Level.FINE, "OGL: Creating display context ..");
-
-        // Set renderable to true, since canvas is now displayable.
-        renderable.set(true);
-        createContext(settings);
-
-        logger.log(Level.FINE, "OGL: Display is active!");
-
-        try {
-            if (mouseWasCreated){
-                Mouse.create();
-                if (mouseWasGrabbed){
-                    Mouse.setGrabbed(true);
-                    mouseWasGrabbed = false;
-                }
-            }
-            if (keyboardWasCreated){
-                Keyboard.create();
-                keyboardWasCreated = false;
-            }
-        } catch (LWJGLException ex){
-            logger.log(Level.SEVERE, "Encountered exception when restoring input", ex);
-        }
-
-        SwingUtilities.invokeLater(new Runnable(){
-            @Override
-            public void run(){
-                canvas.requestFocus();
-            }
-        });
-    }
-    
-    /**
-     * It seems it is best to use one pixel format for all shared contexts.
-     * @see <a href="http://developer.apple.com/library/mac/#qa/qa1248/_index.html">http://developer.apple.com/library/mac/#qa/qa1248/_index.html</a>
-     * 
-     * @param forPbuffer true&rarr;zero samples, false&rarr;correct number of samples
-     * @return a new instance
-     */
-    protected PixelFormat acquirePixelFormat(boolean forPbuffer){
-        if (forPbuffer){
-            // Use 0 samples for pbuffer format, prevents
-            // crashes on bad drivers
-            if (pbufferFormat == null){
-                pbufferFormat = new PixelFormat(settings.getBitsPerPixel(),
-                                                settings.getAlphaBits(),
-                                                settings.getDepthBits(),
-                                                settings.getStencilBits(),
-                                                0, // samples
-                                                0,
-                                                0, 
-                                                0, 
-                                                settings.useStereo3D());
-            }
-            return pbufferFormat;
-        }else{
-            if (canvasFormat == null){
-                int samples = getNumSamplesToUse();
-                canvasFormat = new PixelFormat(settings.getBitsPerPixel(),
-                                               settings.getAlphaBits(),
-                                               settings.getDepthBits(),
-                                               settings.getStencilBits(),
-                                               samples,
-                                               0,
-                                               0, 
-                                               0, 
-                                               settings.useStereo3D());
-            }
-            return canvasFormat;
-        }
-    }
-
-    /**
-     * Makes sure the pbuffer is available and ready for use
-     * 
-     * @throws LWJGLException if the buffer can't be made current
-     */
-    protected void makePbufferAvailable() throws LWJGLException{
-        if (pbuffer != null && pbuffer.isBufferLost()){
-            logger.log(Level.WARNING, "PBuffer was lost!");
-            pbuffer.destroy();
-            pbuffer = null;
-        }
-        
-        if (pbuffer == null) {
-            pbuffer = new Pbuffer(1, 1, acquirePixelFormat(true), null);
-            pbuffer.makeCurrent();
-            logger.log(Level.FINE, "OGL: Pbuffer has been created");
-            
-            // Any created objects are no longer valid
-            if (!runningFirstTime){
-                renderer.resetGLObjects();
-            }
-        }
-        
-        pbuffer.makeCurrent();
-        if (!pbuffer.isCurrent()){
-            throw new LWJGLException("Pbuffer cannot be made current");
-        }
-    }
-    
-    protected void destroyPbuffer(){
-        if (pbuffer != null){
-            if (!pbuffer.isBufferLost()){
-                pbuffer.destroy();
-            }
-            pbuffer = null;
-        }
-    }
-    
-    /**
-     * This is called:
-     * 1) When the context thread ends
-     * 2) Any time the canvas becomes non-displayable
-     */
-    @Override
-    protected void destroyContext(){
-        try {
-            // invalidate the state so renderer can resume operation
-            if (!USE_SHARED_CONTEXT){
-                renderer.cleanup();
-            }
-            
-            if (Display.isCreated()){
-                /* FIXES:
-                 * org.lwjgl.LWJGLException: X Error
-                 * BadWindow (invalid Window parameter) request_code: 2 minor_code: 0
-                 * 
-                 * Destroying keyboard early prevents the error above, triggered
-                 * by destroying keyboard in by Display.destroy() or Display.setParent(null).
-                 * Therefore, Keyboard.destroy() should precede any of these calls.
-                 */
-                if (Keyboard.isCreated()){
-                    // Should only happen if called in 
-                    // LwjglAbstractDisplay.deinitInThread().
-                    Keyboard.destroy();
-                }
-
-                //try {
-                    // NOTE: On Windows XP, not calling setParent(null)
-                    // freezes the application.
-                    // On Mac it freezes the application.
-                    // On Linux it fixes a crash with X Window System.
-                    if (JmeSystem.getPlatform() == Platform.Windows32
-                     || JmeSystem.getPlatform() == Platform.Windows64){
-                        //Display.setParent(null);
-                    }
-                //} catch (LWJGLException ex) {
-                //    logger.log(Level.SEVERE, "Encountered exception when setting parent to null", ex);
-                //}
-
-                Display.destroy();
-            }
-            
-            // The canvas is no longer visible,
-            // but the context thread is still running.
-            if (!needClose.get()){
-                // MUST make sure there's still a context current here.
-                // Display is dead, make PBuffer available to the system.
-                makePbufferAvailable();
-                
-                renderer.invalidateState();
-            }else{
-                // The context thread is no longer running.
-                // Destroy pbuffer.
-                destroyPbuffer();
-            }
-        } catch (LWJGLException ex) {
-            listener.handleError("Failed make pbuffer available", ex);
-        }
-    }
-
-    /**
-     * This is called:
-     * 1) When the context thread starts
-     * 2) Any time the canvas becomes displayable again.
-	 * In the first call of this method, OpenGL context is not ready yet. Therefore, OpenCL context cannot be created.
-	 * The second call of this method is done after "simpleInitApp" is called. Therefore, OpenCL won't be available in "simpleInitApp" if Canvas/Swing is used.
-	 * To use OpenCL with Canvas/Swing, you need to use OpenCL in the rendering loop "simpleUpdate" and check for "context.getOpenCLContext()!=null".
-     */
-    @Override
-    protected void createContext(AppSettings settings) {
-        // In case canvas is not visible, we still take framerate
-        // from settings to prevent "100% CPU usage"
-        frameRate = settings.getFrameRate();
-        allowSwapBuffers = settings.isSwapBuffers();
-        
-        try {
-            if (renderable.get()){
-                if (!runningFirstTime){
-                    // because the display is a different opengl context
-                    // must reset the context state.
-                    if (!USE_SHARED_CONTEXT){
-                        renderer.cleanup();
-                    }
-                }
-                
-                // if the pbuffer is currently active, 
-                // make sure to deactivate it
-                destroyPbuffer();
-                
-                if (Keyboard.isCreated()){
-                    Keyboard.destroy();
-                }
-                
-                try {
-                    Thread.sleep(1000);
-                } catch (InterruptedException ex) {
-                }
-                
-                Display.setVSyncEnabled(settings.isVSync());
-                Display.setParent(canvas);
-                
-                if (USE_SHARED_CONTEXT){
-                    Display.create(acquirePixelFormat(false), pbuffer);
-                }else{
-                    Display.create(acquirePixelFormat(false));
-                }
-				if (settings.isOpenCLSupport()) {
-					initOpenCL();
-				}
-				
-                renderer.invalidateState();
-            }else{
-                // First create the pbuffer, if it is needed.
-                makePbufferAvailable();
-            }
-
-            // At this point, the OpenGL context is active.
-            if (runningFirstTime){
-                // THIS is the part that creates the renderer.
-                // It must always be called, now that we have the pbuffer workaround.
-                initContextFirstTime();
-                runningFirstTime = false;
-            }
-        } catch (LWJGLException ex) {
-            listener.handleError("Failed to initialize OpenGL context", ex);
-            // TODO: Fix deadlock that happens after the error (throw runtime exception?)
-        }
-    }
-}
+/*
+ * Copyright (c) 2009-2021 jMonkeyEngine All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification, are permitted
+ * provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice, this list of conditions
+ * and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright notice, this list of
+ * conditions and the following disclaimer in the documentation and/or other materials provided with
+ * the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors may be used to endorse or
+ * promote products derived from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
+ * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
+ * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+ * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
+ * WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package com.jme3.system.lwjgl;
+
+import com.jme3.system.AppSettings;
+import com.jme3.system.Displays;
+import com.jme3.system.JmeCanvasContext;
+import com.jme3.system.JmeContext.Type;
+import com.jme3.system.JmeSystem;
+import com.jme3.system.Platform;
+import java.awt.Canvas;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import javax.swing.SwingUtilities;
+import org.lwjgl.LWJGLException;
+import org.lwjgl.input.Keyboard;
+import org.lwjgl.input.Mouse;
+import org.lwjgl.opengl.Display;
+import org.lwjgl.opengl.Pbuffer;
+import org.lwjgl.opengl.PixelFormat;
+
+public class LwjglCanvas extends LwjglAbstractDisplay implements JmeCanvasContext {
+
+    protected static final int TASK_NOTHING = 0, TASK_DESTROY_DISPLAY = 1, TASK_CREATE_DISPLAY =
+        2, TASK_COMPLETE = 3;
+
+    // protected static final boolean USE_SHARED_CONTEXT =
+    // Boolean.parseBoolean(System.getProperty("jme3.canvas.sharedctx", "true"));
+
+    protected static final boolean USE_SHARED_CONTEXT = false;
+
+    private static final Logger logger = Logger.getLogger(LwjglDisplay.class.getName());
+    private Canvas canvas;
+    private int width;
+    private int height;
+
+    private final Object taskLock = new Object();
+    private int desiredTask = TASK_NOTHING;
+
+    private Thread renderThread;
+    private boolean runningFirstTime = true;
+    private boolean mouseWasGrabbed = false;
+
+    private boolean mouseWasCreated = false;
+    private boolean keyboardWasCreated = false;
+
+    private Pbuffer pbuffer;
+    private PixelFormat pbufferFormat;
+    private PixelFormat canvasFormat;
+
+    private class GLCanvas extends Canvas {
+
+        @Override
+        public void addNotify() {
+            super.addNotify();
+
+            if (renderThread != null && renderThread.getState() == Thread.State.TERMINATED) {
+                return; // already destroyed.
+            }
+
+            if (renderThread == null) {
+                logger.log(Level.FINE, "EDT: Creating OGL thread.");
+
+                // Also set some settings on the canvas here.
+                // So we don't do it outside the AWT thread.
+                canvas.setFocusable(true);
+                canvas.setIgnoreRepaint(true);
+
+                renderThread = new Thread(LwjglCanvas.this, THREAD_NAME);
+                renderThread.start();
+            } else if (needClose.get()) {
+                return;
+            }
+
+            logger.log(Level.FINE, "EDT: Telling OGL to create display ..");
+            synchronized (taskLock) {
+                desiredTask = TASK_CREATE_DISPLAY;
+                // while (desiredTask != TASK_COMPLETE){
+                // try {
+                // taskLock.wait();
+                // } catch (InterruptedException ex) {
+                // return;
+                // }
+                // }
+                // desiredTask = TASK_NOTHING;
+            }
+            // logger.log(Level.FINE, "EDT: OGL has created the display");
+        }
+
+        @Override
+        public void removeNotify() {
+            if (needClose.get()) {
+                logger.log(Level.FINE, "EDT: Application is stopped. Not restoring canvas.");
+                super.removeNotify();
+                return;
+            }
+
+            // We must tell GL context to shut down and wait for it to
+            // shut down. Otherwise, issues will occur.
+            logger.log(Level.FINE, "EDT: Telling OGL to destroy display ..");
+            synchronized (taskLock) {
+                desiredTask = TASK_DESTROY_DISPLAY;
+                while (desiredTask != TASK_COMPLETE) {
+                    try {
+                        taskLock.wait();
+                    } catch (InterruptedException ex) {
+                        super.removeNotify();
+                        return;
+                    }
+                }
+                desiredTask = TASK_NOTHING;
+            }
+
+            logger.log(Level.FINE, "EDT: Acknowledged receipt of canvas death");
+            // GL context is dead at this point
+
+            super.removeNotify();
+        }
+    }
+
+    public LwjglCanvas() {
+        super();
+        canvas = new GLCanvas();
+    }
+
+    @Override
+    public Type getType() {
+        return Type.Canvas;
+    }
+
+    @Override
+    public void create(boolean waitFor) {
+        if (renderThread == null) {
+            logger.log(Level.FINE, "MAIN: Creating OGL thread.");
+
+            renderThread = new Thread(LwjglCanvas.this, THREAD_NAME);
+            renderThread.start();
+        }
+        // do not do anything.
+        // superclass's create() will be called at initInThread()
+        if (waitFor) {
+            waitFor(true);
+        }
+    }
+
+    @Override
+    public void setTitle(String title) {}
+
+    @Override
+    public void restart() {
+        frameRate = settings.getFrameRate();
+        // TODO: Handle other cases, like change of pixel format, etc.
+    }
+
+    @Override
+    public Canvas getCanvas() {
+        return canvas;
+    }
+
+    @Override
+    protected void runLoop() {
+        if (desiredTask != TASK_NOTHING) {
+            synchronized (taskLock) {
+                switch (desiredTask) {
+                    case TASK_CREATE_DISPLAY:
+                        logger.log(Level.FINE, "OGL: Creating display ..");
+                        restoreCanvas();
+                        listener.gainFocus();
+                        desiredTask = TASK_NOTHING;
+                        break;
+                    case TASK_DESTROY_DISPLAY:
+                        logger.log(Level.FINE, "OGL: Destroying display ..");
+                        listener.loseFocus();
+                        pauseCanvas();
+                        break;
+                }
+                desiredTask = TASK_COMPLETE;
+                taskLock.notifyAll();
+            }
+        }
+
+        if (renderable.get()) {
+            int newWidth = Math.max(canvas.getWidth(), 1);
+            int newHeight = Math.max(canvas.getHeight(), 1);
+            if (width != newWidth || height != newHeight) {
+                width = newWidth;
+                height = newHeight;
+                if (listener != null) {
+                    listener.reshape(width, height);
+                }
+            }
+        } else {
+            if (frameRate <= 0) {
+                // NOTE: MUST be done otherwise
+                // Windows OS will freeze
+                Display.sync(30);
+            }
+        }
+
+        super.runLoop();
+    }
+
+    private void pauseCanvas() {
+        if (Mouse.isCreated()) {
+            if (Mouse.isGrabbed()) {
+                Mouse.setGrabbed(false);
+                mouseWasGrabbed = true;
+            }
+            mouseWasCreated = true;
+            Mouse.destroy();
+        }
+        if (Keyboard.isCreated()) {
+            keyboardWasCreated = true;
+            Keyboard.destroy();
+        }
+
+        renderable.set(false);
+        destroyContext();
+    }
+
+    /**
+     * Called to restore the canvas.
+     */
+    private void restoreCanvas() {
+        logger.log(Level.FINE, "OGL: Waiting for canvas to become displayable..");
+        while (!canvas.isDisplayable()) {
+            try {
+                Thread.sleep(10);
+            } catch (InterruptedException ex) {
+                logger.log(Level.SEVERE, "OGL: Interrupted! ", ex);
+            }
+        }
+
+        logger.log(Level.FINE, "OGL: Creating display context ..");
+
+        // Set renderable to true, since canvas is now displayable.
+        renderable.set(true);
+        createContext(settings);
+
+        logger.log(Level.FINE, "OGL: Display is active!");
+
+        try {
+            if (mouseWasCreated) {
+                Mouse.create();
+                if (mouseWasGrabbed) {
+                    Mouse.setGrabbed(true);
+                    mouseWasGrabbed = false;
+                }
+            }
+            if (keyboardWasCreated) {
+                Keyboard.create();
+                keyboardWasCreated = false;
+            }
+        } catch (LWJGLException ex) {
+            logger.log(Level.SEVERE, "Encountered exception when restoring input", ex);
+        }
+
+        SwingUtilities.invokeLater(
+            new Runnable() {
+                @Override
+                public void run() {
+                    canvas.requestFocus();
+                }
+            }
+        );
+    }
+
+    /**
+     * It seems it is best to use one pixel format for all shared contexts.
+     *
+     * @see <a href=
+     *      "http://developer.apple.com/library/mac/#qa/qa1248/_index.html">http://developer.apple.com/library/mac/#qa/qa1248/_index.html</a>
+     *
+     * @param forPbuffer true&rarr;zero samples, false&rarr;correct number of samples
+     * @return a new instance
+     */
+    protected PixelFormat acquirePixelFormat(boolean forPbuffer) {
+        if (forPbuffer) {
+            // Use 0 samples for pbuffer format, prevents
+            // crashes on bad drivers
+            if (pbufferFormat == null) {
+                pbufferFormat =
+                    new PixelFormat(
+                        settings.getBitsPerPixel(),
+                        settings.getAlphaBits(),
+                        settings.getDepthBits(),
+                        settings.getStencilBits(),
+                        0, // samples
+                        0,
+                        0,
+                        0,
+                        settings.useStereo3D()
+                    );
+            }
+            return pbufferFormat;
+        } else {
+            if (canvasFormat == null) {
+                int samples = getNumSamplesToUse();
+                canvasFormat =
+                    new PixelFormat(
+                        settings.getBitsPerPixel(),
+                        settings.getAlphaBits(),
+                        settings.getDepthBits(),
+                        settings.getStencilBits(),
+                        samples,
+                        0,
+                        0,
+                        0,
+                        settings.useStereo3D()
+                    );
+            }
+            return canvasFormat;
+        }
+    }
+
+    /**
+     * Makes sure the pbuffer is available and ready for use
+     *
+     * @throws LWJGLException if the buffer can't be made current
+     */
+    protected void makePbufferAvailable() throws LWJGLException {
+        if (pbuffer != null && pbuffer.isBufferLost()) {
+            logger.log(Level.WARNING, "PBuffer was lost!");
+            pbuffer.destroy();
+            pbuffer = null;
+        }
+
+        if (pbuffer == null) {
+            pbuffer = new Pbuffer(1, 1, acquirePixelFormat(true), null);
+            pbuffer.makeCurrent();
+            logger.log(Level.FINE, "OGL: Pbuffer has been created");
+
+            // Any created objects are no longer valid
+            if (!runningFirstTime) {
+                renderer.resetGLObjects();
+            }
+        }
+
+        pbuffer.makeCurrent();
+        if (!pbuffer.isCurrent()) {
+            throw new LWJGLException("Pbuffer cannot be made current");
+        }
+    }
+
+    protected void destroyPbuffer() {
+        if (pbuffer != null) {
+            if (!pbuffer.isBufferLost()) {
+                pbuffer.destroy();
+            }
+            pbuffer = null;
+        }
+    }
+
+    /**
+     * This is called: 1) When the context thread ends 2) Any time the canvas becomes non-displayable
+     */
+    @Override
+    protected void destroyContext() {
+        try {
+            // invalidate the state so renderer can resume operation
+            if (!USE_SHARED_CONTEXT) {
+                renderer.cleanup();
+            }
+
+            if (Display.isCreated()) {
+                /*
+                 * FIXES: org.lwjgl.LWJGLException: X Error BadWindow (invalid Window parameter)
+                 * request_code: 2 minor_code: 0
+                 *
+                 * Destroying keyboard early prevents the error above, triggered by destroying keyboard in
+                 * by Display.destroy() or Display.setParent(null). Therefore, Keyboard.destroy() should
+                 * precede any of these calls.
+                 */
+                if (Keyboard.isCreated()) {
+                    // Should only happen if called in
+                    // LwjglAbstractDisplay.deinitInThread().
+                    Keyboard.destroy();
+                }
+
+                // try {
+                // NOTE: On Windows XP, not calling setParent(null)
+                // freezes the application.
+                // On Mac it freezes the application.
+                // On Linux it fixes a crash with X Window System.
+                if (
+                    JmeSystem.getPlatform() == Platform.Windows32 ||
+                    JmeSystem.getPlatform() == Platform.Windows64
+                ) {
+                    // Display.setParent(null);
+                }
+                // } catch (LWJGLException ex) {
+                // logger.log(Level.SEVERE, "Encountered exception when setting parent to null", ex);
+                // }
+
+                Display.destroy();
+            }
+
+            // The canvas is no longer visible,
+            // but the context thread is still running.
+            if (!needClose.get()) {
+                // MUST make sure there's still a context current here.
+                // Display is dead, make PBuffer available to the system.
+                makePbufferAvailable();
+
+                renderer.invalidateState();
+            } else {
+                // The context thread is no longer running.
+                // Destroy pbuffer.
+                destroyPbuffer();
+            }
+        } catch (LWJGLException ex) {
+            listener.handleError("Failed make pbuffer available", ex);
+        }
+    }
+
+    /**
+     * This is called: 1) When the context thread starts 2) Any time the canvas becomes displayable
+     * again. In the first call of this method, OpenGL context is not ready yet. Therefore, OpenCL
+     * context cannot be created. The second call of this method is done after "simpleInitApp" is
+     * called. Therefore, OpenCL won't be available in "simpleInitApp" if Canvas/Swing is used. To use
+     * OpenCL with Canvas/Swing, you need to use OpenCL in the rendering loop "simpleUpdate" and check
+     * for "context.getOpenCLContext()!=null".
+     */
+    @Override
+    protected void createContext(AppSettings settings) {
+        // In case canvas is not visible, we still take framerate
+        // from settings to prevent "100% CPU usage"
+        frameRate = settings.getFrameRate();
+        allowSwapBuffers = settings.isSwapBuffers();
+
+        try {
+            if (renderable.get()) {
+                if (!runningFirstTime) {
+                    // because the display is a different opengl context
+                    // must reset the context state.
+                    if (!USE_SHARED_CONTEXT) {
+                        renderer.cleanup();
+                    }
+                }
+
+                // if the pbuffer is currently active,
+                // make sure to deactivate it
+                destroyPbuffer();
+
+                if (Keyboard.isCreated()) {
+                    Keyboard.destroy();
+                }
+
+                try {
+                    Thread.sleep(1000);
+                } catch (InterruptedException ex) {}
+
+                Display.setVSyncEnabled(settings.isVSync());
+                Display.setParent(canvas);
+
+                if (USE_SHARED_CONTEXT) {
+                    Display.create(acquirePixelFormat(false), pbuffer);
+                } else {
+                    Display.create(acquirePixelFormat(false));
+                }
+                if (settings.isOpenCLSupport()) {
+                    initOpenCL();
+                }
+
+                renderer.invalidateState();
+            } else {
+                // First create the pbuffer, if it is needed.
+                makePbufferAvailable();
+            }
+
+            // At this point, the OpenGL context is active.
+            if (runningFirstTime) {
+                // THIS is the part that creates the renderer.
+                // It must always be called, now that we have the pbuffer workaround.
+                initContextFirstTime();
+                runningFirstTime = false;
+            }
+        } catch (LWJGLException ex) {
+            listener.handleError("Failed to initialize OpenGL context", ex);
+            // TODO: Fix deadlock that happens after the error (throw runtime exception?)
+        }
+    }
+
+    @Override
+    public Displays getDisplays() {
+        // TODO Auto-generated method stub
+        return null;
+    }
+
+    @Override
+    public int getPrimaryDisplay() {
+        // TODO Auto-generated method stub
+        return 0;
+    }
+}

+ 325 - 285
jme3-lwjgl/src/main/java/com/jme3/system/lwjgl/LwjglDisplay.java

@@ -1,285 +1,325 @@
-/*
- * Copyright (c) 2009-2023 jMonkeyEngine
- * All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are
- * met:
- *
- * * Redistributions of source code must retain the above copyright
- *   notice, this list of conditions and the following disclaimer.
- *
- * * Redistributions in binary form must reproduce the above copyright
- *   notice, this list of conditions and the following disclaimer in the
- *   documentation and/or other materials provided with the distribution.
- *
- * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
- *   may be used to endorse or promote products derived from this software
- *   without specific prior written permission.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
- * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
- * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
- * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
- * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
- * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
- * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
- * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
- * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
- * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
- * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- */
-
-package com.jme3.system.lwjgl;
-
-import com.jme3.system.AppSettings;
-import com.jme3.system.JmeContext.Type;
-import java.awt.Graphics2D;
-import java.awt.image.BufferedImage;
-import java.nio.ByteBuffer;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-import org.lwjgl.LWJGLException;
-import org.lwjgl.opengl.*;
-
-public class LwjglDisplay extends LwjglAbstractDisplay {
-
-    private static final Logger logger = Logger.getLogger(LwjglDisplay.class.getName());
-
-    private final AtomicBoolean needRestart = new AtomicBoolean(false);
-    private PixelFormat pixelFormat;
-
-    /**
-     * @param width The required display width
-     * @param height The required display height
-     * @param bpp The required bits per pixel. If -1 is passed it will return
-     *           whatever bpp is found
-     * @param freq The required frequency, if -1 is passed it will return
-     *             whatever frequency is found
-     * @return The {@link DisplayMode} matches with specified settings or
-     *         return null if no matching display mode is found
-     */
-    protected DisplayMode getFullscreenDisplayMode(int width, int height, int bpp, int freq){
-        try {
-            DisplayMode[] modes = Display.getAvailableDisplayModes();
-            for (DisplayMode mode : modes) {
-                if (mode.getWidth() == width
-                        && mode.getHeight() == height
-                        && (mode.getBitsPerPixel() == bpp || (bpp == 24 && mode.getBitsPerPixel() == 32) || bpp == -1)
-                        // Looks like AWT uses mathematical round to convert floating point
-                        // frequency values to int while lwjgl 2 uses mathematical floor.
-                        // For example if frequency is 59.83, AWT will return 60 but lwjgl2
-                        // will return 59. This is what I observed on Linux.  - Ali-RS 2023-1-10
-                        && (Math.abs(mode.getFrequency() - freq) <= 1 || freq == -1)) {
-                    return mode;
-                }
-            }
-        } catch (LWJGLException ex) {
-            listener.handleError("Failed to acquire fullscreen display mode!", ex);
-        }
-        return null;
-    }
-
-    @Override
-    protected void createContext(AppSettings settings) throws LWJGLException{
-        DisplayMode displayMode;
-        if (settings.getWidth() <= 0 || settings.getHeight() <= 0) {
-            displayMode = Display.getDesktopDisplayMode();
-            settings.setResolution(displayMode.getWidth(), displayMode.getHeight());
-        } else if (settings.isFullscreen()) {
-            displayMode = getFullscreenDisplayMode(settings.getWidth(), settings.getHeight(),
-                    settings.getBitsPerPixel(), settings.getFrequency());
-            if (displayMode == null) {
-                // Fall back to whatever mode is available at the specified width & height
-                displayMode = getFullscreenDisplayMode(settings.getWidth(), settings.getHeight(), -1, -1);
-                if (displayMode == null) {
-                    throw new RuntimeException("Unable to find fullscreen display mode matching settings");
-                } else {
-                    logger.log(Level.WARNING, "Unable to find fullscreen display mode matching settings, falling back to: {0}", displayMode);
-                }
-            }
-        } else {
-            displayMode = new DisplayMode(settings.getWidth(), settings.getHeight());
-        }
-
-        int samples = getNumSamplesToUse();
-        PixelFormat pf = new PixelFormat(settings.getBitsPerPixel(),
-                                         settings.getAlphaBits(),
-                                         settings.getDepthBits(),
-                                         settings.getStencilBits(),
-                                         samples, 
-                                         0, 
-                                         0, 
-                                         0, 
-                                         settings.useStereo3D());
-        
-        frameRate = settings.getFrameRate();
-        allowSwapBuffers = settings.isSwapBuffers();
-        logger.log(Level.FINE, "Selected display mode: {0}", displayMode);
-
-        boolean pixelFormatChanged = false;
-        if (created.get() && (pixelFormat.getBitsPerPixel() != pf.getBitsPerPixel()
-                            ||pixelFormat.getAlphaBits() != pf.getAlphaBits()
-                            ||pixelFormat.getDepthBits() != pf.getDepthBits()
-                            ||pixelFormat.getStencilBits() != pf.getStencilBits()
-                            ||pixelFormat.getSamples() != pf.getSamples())){
-            renderer.resetGLObjects();
-            Display.destroy();
-            pixelFormatChanged = true;
-        }
-        pixelFormat = pf;
-        
-        Display.setTitle(settings.getTitle());
-        Display.setResizable(settings.isResizable());
-        
-        if (settings.isFullscreen()) {
-            Display.setDisplayModeAndFullscreen(displayMode);
-        } else {
-            Display.setFullscreen(false);
-            Display.setDisplayMode(displayMode);
-        }
-
-        if (settings.getIcons() != null) {
-            Display.setIcon(imagesToByteBuffers(settings.getIcons()));
-        }
-        
-        Display.setVSyncEnabled(settings.isVSync());
-        
-        if (created.get() && !pixelFormatChanged) {
-            renderer.resetGLObjects();
-            Display.releaseContext();
-            Display.makeCurrent();
-            Display.update();
-        }
-
-        if (!created.get() || pixelFormatChanged){
-            ContextAttribs attr = createContextAttribs();
-            if (attr != null) {
-                Display.create(pixelFormat, attr);
-            } else {
-                Display.create(pixelFormat);
-            }
-            renderable.set(true);
-            
-            if (pixelFormatChanged && pixelFormat.getSamples() > 1
-             && GLContext.getCapabilities().GL_ARB_multisample){
-                GL11.glEnable(ARBMultisample.GL_MULTISAMPLE_ARB);
-            }
-        }
-        
-        if (settings.isOpenCLSupport()) {
-            initOpenCL();
-        }
-    }
-    
-    @Override
-    protected void destroyContext(){
-        try {
-            renderer.cleanup();
-            Display.releaseContext();
-            Display.destroy();
-        } catch (LWJGLException ex) {
-            listener.handleError("Failed to destroy context", ex);
-        }
-    }
-
-    @Override
-    public void create(boolean waitFor){
-        if (created.get()){
-            logger.warning("create() called when display is already created!");
-            return;
-        }
-
-        new Thread(this, THREAD_NAME).start();
-        if (waitFor)
-            waitFor(true);
-    }
-
-    @Override
-    public void runLoop(){
-        // This method is overridden to do restart
-        if (needRestart.getAndSet(false)) {
-            try {
-                createContext(settings);
-            } catch (LWJGLException ex) {
-                logger.log(Level.SEVERE, "Failed to set display settings!", ex);
-            }
-            listener.reshape(settings.getWidth(), settings.getHeight());
-            if (renderable.get()) {
-                reinitContext();
-            } else {
-                assert getType() == Type.Canvas;
-            }
-            logger.fine("Display restarted.");
-        } else if (Display.wasResized()) {
-            int newWidth = Display.getWidth();
-            int newHeight = Display.getHeight();
-            settings.setResolution(newWidth, newHeight);
-            listener.reshape(newWidth, newHeight);
-        }
-
-        super.runLoop();
-    }
-
-    @Override
-    public void restart() {
-        if (created.get()){
-            needRestart.set(true);
-        }else{
-            logger.warning("Display is not created, cannot restart window.");
-        }
-    }
-
-    @Override
-    public Type getType() {
-        return Type.Display;
-    }
-
-    @Override
-    public void setTitle(String title){
-        if (created.get())
-            Display.setTitle(title);
-    }
-    
-    private ByteBuffer[] imagesToByteBuffers(Object[] images) {
-        ByteBuffer[] out = new ByteBuffer[images.length];
-        for (int i = 0; i < images.length; i++) {
-            BufferedImage image = (BufferedImage) images[i];
-            out[i] = imageToByteBuffer(image);
-        }
-        return out;
-    }
-
-    private ByteBuffer imageToByteBuffer(BufferedImage image) {
-        if (image.getType() != BufferedImage.TYPE_INT_ARGB_PRE) {
-            BufferedImage convertedImage = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB_PRE);
-            Graphics2D g = convertedImage.createGraphics();
-            double width = image.getWidth() * (double) 1;
-            double height = image.getHeight() * (double) 1;
-            g.drawImage(image, (int) ((convertedImage.getWidth() - width) / 2),
-                    (int) ((convertedImage.getHeight() - height) / 2),
-                    (int) (width), (int) (height), null);
-            g.dispose();
-            image = convertedImage;
-        }
-
-        byte[] imageBuffer = new byte[image.getWidth() * image.getHeight() * 4];
-        int counter = 0;
-        for (int i = 0; i < image.getHeight(); i++) {
-            for (int j = 0; j < image.getWidth(); j++) {
-                int colorSpace = image.getRGB(j, i);
-                imageBuffer[counter + 0] = (byte) ((colorSpace << 8) >> 24);
-                imageBuffer[counter + 1] = (byte) ((colorSpace << 16) >> 24);
-                imageBuffer[counter + 2] = (byte) ((colorSpace << 24) >> 24);
-                imageBuffer[counter + 3] = (byte) (colorSpace >> 24);
-                counter += 4;
-            }
-        }
-        return ByteBuffer.wrap(imageBuffer);
-    }
-
-}
+/*
+ * Copyright (c) 2009-2023 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package com.jme3.system.lwjgl;
+
+import com.jme3.system.AppSettings;
+import com.jme3.system.Displays;
+import com.jme3.system.JmeContext.Type;
+import java.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.lwjgl.LWJGLException;
+import org.lwjgl.opengl.*;
+
+public class LwjglDisplay extends LwjglAbstractDisplay {
+
+    private static final Logger logger = Logger.getLogger(LwjglDisplay.class.getName());
+
+    private final AtomicBoolean needRestart = new AtomicBoolean(false);
+    private PixelFormat pixelFormat;
+
+    /**
+     * @param width The required display width
+     * @param height The required display height
+     * @param bpp The required bits per pixel. If -1 is passed it will return
+     *           whatever bpp is found
+     * @param freq The required frequency, if -1 is passed it will return
+     *             whatever frequency is found
+     * @return The {@link DisplayMode} matches with specified settings or
+     *         return null if no matching display mode is found
+     */
+    protected DisplayMode getFullscreenDisplayMode(int width, int height, int bpp, int freq) {
+        try {
+            DisplayMode[] modes = Display.getAvailableDisplayModes();
+            for (DisplayMode mode : modes) {
+                if (
+                    mode.getWidth() == width &&
+                    mode.getHeight() == height &&
+                    (mode.getBitsPerPixel() == bpp ||
+                        (bpp == 24 && mode.getBitsPerPixel() == 32) ||
+                        bpp == -1) &&
+                    // Looks like AWT uses mathematical round to convert floating point
+                    // frequency values to int while lwjgl 2 uses mathematical floor.
+                    // For example if frequency is 59.83, AWT will return 60 but lwjgl2
+                    // will return 59. This is what I observed on Linux.  - Ali-RS 2023-1-10
+                    (Math.abs(mode.getFrequency() - freq) <= 1 || freq == -1)
+                ) {
+                    return mode;
+                }
+            }
+        } catch (LWJGLException ex) {
+            listener.handleError("Failed to acquire fullscreen display mode!", ex);
+        }
+        return null;
+    }
+
+    @Override
+    protected void createContext(AppSettings settings) throws LWJGLException {
+        DisplayMode displayMode;
+        if (settings.getWidth() <= 0 || settings.getHeight() <= 0) {
+            displayMode = Display.getDesktopDisplayMode();
+            settings.setResolution(displayMode.getWidth(), displayMode.getHeight());
+        } else if (settings.isFullscreen()) {
+            displayMode =
+                getFullscreenDisplayMode(
+                    settings.getWidth(),
+                    settings.getHeight(),
+                    settings.getBitsPerPixel(),
+                    settings.getFrequency()
+                );
+            if (displayMode == null) {
+                // Fall back to whatever mode is available at the specified width & height
+                displayMode = getFullscreenDisplayMode(settings.getWidth(), settings.getHeight(), -1, -1);
+                if (displayMode == null) {
+                    throw new RuntimeException("Unable to find fullscreen display mode matching settings");
+                } else {
+                    logger.log(
+                        Level.WARNING,
+                        "Unable to find fullscreen display mode matching settings, falling back to: {0}",
+                        displayMode
+                    );
+                }
+            }
+        } else {
+            displayMode = new DisplayMode(settings.getWidth(), settings.getHeight());
+        }
+
+        int samples = getNumSamplesToUse();
+        PixelFormat pf = new PixelFormat(
+            settings.getBitsPerPixel(),
+            settings.getAlphaBits(),
+            settings.getDepthBits(),
+            settings.getStencilBits(),
+            samples,
+            0,
+            0,
+            0,
+            settings.useStereo3D()
+        );
+
+        frameRate = settings.getFrameRate();
+        allowSwapBuffers = settings.isSwapBuffers();
+        logger.log(Level.FINE, "Selected display mode: {0}", displayMode);
+
+        boolean pixelFormatChanged = false;
+        if (
+            created.get() &&
+            (pixelFormat.getBitsPerPixel() != pf.getBitsPerPixel() ||
+                pixelFormat.getAlphaBits() != pf.getAlphaBits() ||
+                pixelFormat.getDepthBits() != pf.getDepthBits() ||
+                pixelFormat.getStencilBits() != pf.getStencilBits() ||
+                pixelFormat.getSamples() != pf.getSamples())
+        ) {
+            renderer.resetGLObjects();
+            Display.destroy();
+            pixelFormatChanged = true;
+        }
+        pixelFormat = pf;
+
+        Display.setTitle(settings.getTitle());
+        Display.setResizable(settings.isResizable());
+
+        if (settings.isFullscreen()) {
+            Display.setDisplayModeAndFullscreen(displayMode);
+        } else {
+            Display.setFullscreen(false);
+            Display.setDisplayMode(displayMode);
+        }
+
+        if (settings.getIcons() != null) {
+            Display.setIcon(imagesToByteBuffers(settings.getIcons()));
+        }
+
+        Display.setVSyncEnabled(settings.isVSync());
+
+        if (created.get() && !pixelFormatChanged) {
+            renderer.resetGLObjects();
+            Display.releaseContext();
+            Display.makeCurrent();
+            Display.update();
+        }
+
+        if (!created.get() || pixelFormatChanged) {
+            ContextAttribs attr = createContextAttribs();
+            if (attr != null) {
+                Display.create(pixelFormat, attr);
+            } else {
+                Display.create(pixelFormat);
+            }
+            renderable.set(true);
+
+            if (
+                pixelFormatChanged &&
+                pixelFormat.getSamples() > 1 &&
+                GLContext.getCapabilities().GL_ARB_multisample
+            ) {
+                GL11.glEnable(ARBMultisample.GL_MULTISAMPLE_ARB);
+            }
+        }
+
+        if (settings.isOpenCLSupport()) {
+            initOpenCL();
+        }
+    }
+
+    @Override
+    protected void destroyContext() {
+        try {
+            renderer.cleanup();
+            Display.releaseContext();
+            Display.destroy();
+        } catch (LWJGLException ex) {
+            listener.handleError("Failed to destroy context", ex);
+        }
+    }
+
+    @Override
+    public void create(boolean waitFor) {
+        if (created.get()) {
+            logger.warning("create() called when display is already created!");
+            return;
+        }
+
+        new Thread(this, THREAD_NAME).start();
+        if (waitFor) waitFor(true);
+    }
+
+    @Override
+    public void runLoop() {
+        // This method is overridden to do restart
+        if (needRestart.getAndSet(false)) {
+            try {
+                createContext(settings);
+            } catch (LWJGLException ex) {
+                logger.log(Level.SEVERE, "Failed to set display settings!", ex);
+            }
+            listener.reshape(settings.getWidth(), settings.getHeight());
+            if (renderable.get()) {
+                reinitContext();
+            } else {
+                assert getType() == Type.Canvas;
+            }
+            logger.fine("Display restarted.");
+        } else if (Display.wasResized()) {
+            int newWidth = Display.getWidth();
+            int newHeight = Display.getHeight();
+            settings.setResolution(newWidth, newHeight);
+            listener.reshape(newWidth, newHeight);
+        }
+
+        super.runLoop();
+    }
+
+    @Override
+    public void restart() {
+        if (created.get()) {
+            needRestart.set(true);
+        } else {
+            logger.warning("Display is not created, cannot restart window.");
+        }
+    }
+
+    @Override
+    public Type getType() {
+        return Type.Display;
+    }
+
+    @Override
+    public void setTitle(String title) {
+        if (created.get()) Display.setTitle(title);
+    }
+
+    private ByteBuffer[] imagesToByteBuffers(Object[] images) {
+        ByteBuffer[] out = new ByteBuffer[images.length];
+        for (int i = 0; i < images.length; i++) {
+            BufferedImage image = (BufferedImage) images[i];
+            out[i] = imageToByteBuffer(image);
+        }
+        return out;
+    }
+
+    private ByteBuffer imageToByteBuffer(BufferedImage image) {
+        if (image.getType() != BufferedImage.TYPE_INT_ARGB_PRE) {
+            BufferedImage convertedImage = new BufferedImage(
+                image.getWidth(),
+                image.getHeight(),
+                BufferedImage.TYPE_INT_ARGB_PRE
+            );
+            Graphics2D g = convertedImage.createGraphics();
+            double width = image.getWidth() * (double) 1;
+            double height = image.getHeight() * (double) 1;
+            g.drawImage(
+                image,
+                (int) ((convertedImage.getWidth() - width) / 2),
+                (int) ((convertedImage.getHeight() - height) / 2),
+                (int) (width),
+                (int) (height),
+                null
+            );
+            g.dispose();
+            image = convertedImage;
+        }
+
+        byte[] imageBuffer = new byte[image.getWidth() * image.getHeight() * 4];
+        int counter = 0;
+        for (int i = 0; i < image.getHeight(); i++) {
+            for (int j = 0; j < image.getWidth(); j++) {
+                int colorSpace = image.getRGB(j, i);
+                imageBuffer[counter + 0] = (byte) ((colorSpace << 8) >> 24);
+                imageBuffer[counter + 1] = (byte) ((colorSpace << 16) >> 24);
+                imageBuffer[counter + 2] = (byte) ((colorSpace << 24) >> 24);
+                imageBuffer[counter + 3] = (byte) (colorSpace >> 24);
+                counter += 4;
+            }
+        }
+        return ByteBuffer.wrap(imageBuffer);
+    }
+
+    @Override
+    public Displays getDisplays() {
+        // TODO Auto-generated method stub
+        return null;
+    }
+
+    @Override
+    public int getPrimaryDisplay() {
+        // TODO Auto-generated method stub
+        return 0;
+    }
+}

+ 233 - 221
jme3-lwjgl/src/main/java/com/jme3/system/lwjgl/LwjglOffscreenBuffer.java

@@ -1,221 +1,233 @@
-/*
- * Copyright (c) 2009-2020 jMonkeyEngine
- * All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are
- * met:
- *
- * * Redistributions of source code must retain the above copyright
- *   notice, this list of conditions and the following disclaimer.
- *
- * * Redistributions in binary form must reproduce the above copyright
- *   notice, this list of conditions and the following disclaimer in the
- *   documentation and/or other materials provided with the distribution.
- *
- * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
- *   may be used to endorse or promote products derived from this software
- *   without specific prior written permission.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
- * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
- * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
- * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
- * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
- * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
- * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
- * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
- * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
- * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
- * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- */
-
-package com.jme3.system.lwjgl;
-
-import com.jme3.input.JoyInput;
-import com.jme3.input.KeyInput;
-import com.jme3.input.MouseInput;
-import com.jme3.input.TouchInput;
-import com.jme3.input.dummy.DummyKeyInput;
-import com.jme3.input.dummy.DummyMouseInput;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-import org.lwjgl.LWJGLException;
-import org.lwjgl.Sys;
-import org.lwjgl.opengl.*;
-
-public class LwjglOffscreenBuffer extends LwjglContext implements Runnable {
-
-    private static final Logger logger = Logger.getLogger(LwjglOffscreenBuffer.class.getName());
-    private Pbuffer pbuffer;
-    protected AtomicBoolean needClose = new AtomicBoolean(false);
-    private int width;
-    private int height;
-    private PixelFormat pixelFormat;
-
-    protected void initInThread(){
-        if ((Pbuffer.getCapabilities() & Pbuffer.PBUFFER_SUPPORTED) == 0){
-            logger.severe("Offscreen surfaces are not supported.");
-            return;
-        }
-
-        int samples = getNumSamplesToUse();
-        pixelFormat = new PixelFormat(settings.getBitsPerPixel(),
-                                      settings.getAlphaBits(),
-                                      settings.getDepthBits(),
-                                      settings.getStencilBits(),
-                                      samples);
-        
-        width = settings.getWidth();
-        height = settings.getHeight();
-        try{
-            Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
-                @Override
-                public void uncaughtException(Thread thread, Throwable thrown) {
-                    listener.handleError("Uncaught exception thrown in "+thread.toString(), thrown);
-                }
-            });
-
-            pbuffer = new Pbuffer(width, height, pixelFormat, null, null, createContextAttribs());
-            pbuffer.makeCurrent();
-
-            renderable.set(true);
-
-            logger.fine("Offscreen buffer created.");
-            printContextInitInfo();
-        } catch (LWJGLException ex){
-            listener.handleError("Failed to create display", ex);
-        } finally {
-            // TODO: It is possible to avoid "Failed to find pixel format"
-            // error here by creating a default display.
-        }
-        super.internalCreate();
-        listener.initialize();
-    }
-
-    protected boolean checkGLError(){
-        try {
-            Util.checkGLError();
-        } catch (OpenGLException ex){
-            listener.handleError("An OpenGL error has occurred!", ex);
-        }
-        // NOTE: Always return true since this is used in an "assert" statement
-        return true;
-    }
-
-    protected void runLoop(){
-        if (!created.get()) {
-            throw new IllegalStateException();
-        }
-
-        if (pbuffer.isBufferLost()) {
-            pbuffer.destroy();
-
-            try {
-                pbuffer = new Pbuffer(width, height, pixelFormat, null);
-                pbuffer.makeCurrent();
-                
-                // Context MUST be reset here to avoid invalid objects!
-                renderer.invalidateState();
-            } catch (LWJGLException ex) {
-                listener.handleError("Failed to restore PBuffer content", ex);
-            }
-        }
-
-        listener.update();
-        assert checkGLError();
-
-        renderer.postFrame();
-        
-        // Need to flush GL commands 
-        // to see any result on the pbuffer's front buffer.
-        GL11.glFlush();
-
-        int frameRate = settings.getFrameRate();
-        if (frameRate >= 1) {
-            Display.sync(frameRate);
-        }
-    }
-
-    protected void deinitInThread(){
-        renderable.set(false);
-
-        listener.destroy();
-        renderer.cleanup();
-        pbuffer.destroy();
-        logger.fine("Offscreen buffer destroyed.");
-        
-        super.internalDestroy();
-    }
-
-    @Override
-    public void run(){
-        loadNatives();
-        if (logger.isLoggable(Level.FINE)) {
-            logger.log(Level.FINE, "Using LWJGL {0}", Sys.getVersion());
-        }
-        initInThread();
-        while (!needClose.get()){
-            runLoop();
-        }
-        deinitInThread();
-    }
-
-    @Override
-    public void destroy(boolean waitFor){
-        needClose.set(true);
-        if (waitFor)
-            waitFor(false);
-    }
-
-    @Override
-    public void create(boolean waitFor){
-        if (created.get()){
-            logger.warning("create() called when pbuffer is already created!");
-            return;
-        }
-
-        new Thread(this, THREAD_NAME).start();
-        if (waitFor)
-            waitFor(true);
-    }
-
-    @Override
-    public void restart() {
-    }
-
-    @Override
-    public void setAutoFlushFrames(boolean enabled){
-    }
-
-    @Override
-    public Type getType() {
-        return Type.OffscreenSurface;
-    }
-
-    @Override
-    public MouseInput getMouseInput() {
-        return new DummyMouseInput();
-    }
-
-    @Override
-    public KeyInput getKeyInput() {
-        return new DummyKeyInput();
-    }
-
-    @Override
-    public JoyInput getJoyInput() {
-        return null;
-    }
-
-    @Override
-    public TouchInput getTouchInput() {
-        return null;
-    }
-
-    @Override
-    public void setTitle(String title) {
-    }
-
-}
+/*
+ * Copyright (c) 2009-2020 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package com.jme3.system.lwjgl;
+
+import com.jme3.input.JoyInput;
+import com.jme3.input.KeyInput;
+import com.jme3.input.MouseInput;
+import com.jme3.input.TouchInput;
+import com.jme3.input.dummy.DummyKeyInput;
+import com.jme3.input.dummy.DummyMouseInput;
+import com.jme3.system.Displays;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.lwjgl.LWJGLException;
+import org.lwjgl.Sys;
+import org.lwjgl.opengl.*;
+
+public class LwjglOffscreenBuffer extends LwjglContext implements Runnable {
+
+    private static final Logger logger = Logger.getLogger(LwjglOffscreenBuffer.class.getName());
+    private Pbuffer pbuffer;
+    protected AtomicBoolean needClose = new AtomicBoolean(false);
+    private int width;
+    private int height;
+    private PixelFormat pixelFormat;
+
+    protected void initInThread() {
+        if ((Pbuffer.getCapabilities() & Pbuffer.PBUFFER_SUPPORTED) == 0) {
+            logger.severe("Offscreen surfaces are not supported.");
+            return;
+        }
+
+        int samples = getNumSamplesToUse();
+        pixelFormat =
+            new PixelFormat(
+                settings.getBitsPerPixel(),
+                settings.getAlphaBits(),
+                settings.getDepthBits(),
+                settings.getStencilBits(),
+                samples
+            );
+
+        width = settings.getWidth();
+        height = settings.getHeight();
+        try {
+            Thread.setDefaultUncaughtExceptionHandler(
+                new Thread.UncaughtExceptionHandler() {
+                    @Override
+                    public void uncaughtException(Thread thread, Throwable thrown) {
+                        listener.handleError("Uncaught exception thrown in " + thread.toString(), thrown);
+                    }
+                }
+            );
+
+            pbuffer = new Pbuffer(width, height, pixelFormat, null, null, createContextAttribs());
+            pbuffer.makeCurrent();
+
+            renderable.set(true);
+
+            logger.fine("Offscreen buffer created.");
+            printContextInitInfo();
+        } catch (LWJGLException ex) {
+            listener.handleError("Failed to create display", ex);
+        } finally {
+            // TODO: It is possible to avoid "Failed to find pixel format"
+            // error here by creating a default display.
+        }
+        super.internalCreate();
+        listener.initialize();
+    }
+
+    protected boolean checkGLError() {
+        try {
+            Util.checkGLError();
+        } catch (OpenGLException ex) {
+            listener.handleError("An OpenGL error has occurred!", ex);
+        }
+        // NOTE: Always return true since this is used in an "assert" statement
+        return true;
+    }
+
+    protected void runLoop() {
+        if (!created.get()) {
+            throw new IllegalStateException();
+        }
+
+        if (pbuffer.isBufferLost()) {
+            pbuffer.destroy();
+
+            try {
+                pbuffer = new Pbuffer(width, height, pixelFormat, null);
+                pbuffer.makeCurrent();
+
+                // Context MUST be reset here to avoid invalid objects!
+                renderer.invalidateState();
+            } catch (LWJGLException ex) {
+                listener.handleError("Failed to restore PBuffer content", ex);
+            }
+        }
+
+        listener.update();
+        assert checkGLError();
+
+        renderer.postFrame();
+
+        // Need to flush GL commands
+        // to see any result on the pbuffer's front buffer.
+        GL11.glFlush();
+
+        int frameRate = settings.getFrameRate();
+        if (frameRate >= 1) {
+            Display.sync(frameRate);
+        }
+    }
+
+    protected void deinitInThread() {
+        renderable.set(false);
+
+        listener.destroy();
+        renderer.cleanup();
+        pbuffer.destroy();
+        logger.fine("Offscreen buffer destroyed.");
+
+        super.internalDestroy();
+    }
+
+    @Override
+    public void run() {
+        loadNatives();
+        if (logger.isLoggable(Level.FINE)) {
+            logger.log(Level.FINE, "Using LWJGL {0}", Sys.getVersion());
+        }
+        initInThread();
+        while (!needClose.get()) {
+            runLoop();
+        }
+        deinitInThread();
+    }
+
+    @Override
+    public void destroy(boolean waitFor) {
+        needClose.set(true);
+        if (waitFor) waitFor(false);
+    }
+
+    @Override
+    public void create(boolean waitFor) {
+        if (created.get()) {
+            logger.warning("create() called when pbuffer is already created!");
+            return;
+        }
+
+        new Thread(this, THREAD_NAME).start();
+        if (waitFor) waitFor(true);
+    }
+
+    @Override
+    public void restart() {}
+
+    @Override
+    public void setAutoFlushFrames(boolean enabled) {}
+
+    @Override
+    public Type getType() {
+        return Type.OffscreenSurface;
+    }
+
+    @Override
+    public MouseInput getMouseInput() {
+        return new DummyMouseInput();
+    }
+
+    @Override
+    public KeyInput getKeyInput() {
+        return new DummyKeyInput();
+    }
+
+    @Override
+    public JoyInput getJoyInput() {
+        return null;
+    }
+
+    @Override
+    public TouchInput getTouchInput() {
+        return null;
+    }
+
+    @Override
+    public void setTitle(String title) {}
+
+    @Override
+    public Displays getDisplays() {
+        // TODO Auto-generated method stub
+        return null;
+    }
+
+    @Override
+    public int getPrimaryDisplay() {
+        // TODO Auto-generated method stub
+        return 0;
+    }
+}

+ 4 - 0
jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglDisplay.java

@@ -31,6 +31,8 @@
  */
 package com.jme3.system.lwjgl;
 
+import com.jme3.system.Displays;
+
 /**
  * @author Daniel Johansson
  */
@@ -39,4 +41,6 @@ public class LwjglDisplay extends LwjglWindow {
     public LwjglDisplay() {
         super(Type.Display);
     }
+
+
 }

+ 284 - 144
jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglWindow.java

@@ -32,6 +32,10 @@
 
 package com.jme3.system.lwjgl;
 
+import static org.lwjgl.glfw.GLFW.*;
+import static org.lwjgl.opengl.GL11.GL_FALSE;
+import static org.lwjgl.system.MemoryUtil.NULL;
+
 import com.jme3.input.JoyInput;
 import com.jme3.input.KeyInput;
 import com.jme3.input.MouseInput;
@@ -41,34 +45,32 @@ import com.jme3.input.lwjgl.GlfwKeyInput;
 import com.jme3.input.lwjgl.GlfwMouseInput;
 import com.jme3.math.Vector2f;
 import com.jme3.system.AppSettings;
+import com.jme3.system.Displays;
 import com.jme3.system.JmeContext;
 import com.jme3.system.JmeSystem;
 import com.jme3.system.NanoTimer;
 import com.jme3.util.BufferUtils;
 import com.jme3.util.SafeArrayList;
-
-import org.lwjgl.Version;
-import org.lwjgl.glfw.GLFWErrorCallback;
-import org.lwjgl.glfw.GLFWFramebufferSizeCallback;
-import org.lwjgl.glfw.GLFWImage;
-import org.lwjgl.glfw.GLFWVidMode;
-import org.lwjgl.glfw.GLFWWindowFocusCallback;
-import org.lwjgl.glfw.GLFWWindowSizeCallback;
-import org.lwjgl.system.Platform;
-
 import java.awt.Graphics2D;
 import java.awt.image.BufferedImage;
 import java.nio.ByteBuffer;
+import java.nio.IntBuffer;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Objects;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.logging.Level;
 import java.util.logging.Logger;
-
-import static org.lwjgl.glfw.GLFW.*;
-import static org.lwjgl.opengl.GL11.GL_FALSE;
-import static org.lwjgl.system.MemoryUtil.NULL;
+import org.lwjgl.PointerBuffer;
+import org.lwjgl.Version;
+import org.lwjgl.glfw.GLFWErrorCallback;
+import org.lwjgl.glfw.GLFWFramebufferSizeCallback;
+import org.lwjgl.glfw.GLFWImage;
+import org.lwjgl.glfw.GLFWVidMode;
+import org.lwjgl.glfw.GLFWWindowFocusCallback;
+import org.lwjgl.glfw.GLFWWindowSizeCallback;
+import org.lwjgl.system.Platform;
 
 /**
  * A wrapper class over the GLFW framework in LWJGL 3.
@@ -80,66 +82,99 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
     private static final Logger LOGGER = Logger.getLogger(LwjglWindow.class.getName());
 
     private static final EnumSet<JmeContext.Type> SUPPORTED_TYPES = EnumSet.of(
-            JmeContext.Type.Display,
-            JmeContext.Type.Canvas,
-            JmeContext.Type.OffscreenSurface);
+        JmeContext.Type.Display,
+        JmeContext.Type.Canvas,
+        JmeContext.Type.OffscreenSurface
+    );
 
     private static final Map<String, Runnable> RENDER_CONFIGS = new HashMap<>();
 
     static {
-        RENDER_CONFIGS.put(AppSettings.LWJGL_OPENGL30, () -> {
-            // Based on GLFW docs for OpenGL version below 3.2,
-            // GLFW_OPENGL_ANY_PROFILE must be used.
-            glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_ANY_PROFILE);
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 0);
-        });
-        RENDER_CONFIGS.put(AppSettings.LWJGL_OPENGL31, () -> {
-            // Based on GLFW docs for OpenGL version below 3.2,
-            // GLFW_OPENGL_ANY_PROFILE must be used.
-            glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_ANY_PROFILE);
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 1);
-        });
-        RENDER_CONFIGS.put(AppSettings.LWJGL_OPENGL32, () -> {
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 2);
-        });
-        RENDER_CONFIGS.put(AppSettings.LWJGL_OPENGL33, () -> {
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
-        });
-        RENDER_CONFIGS.put(AppSettings.LWJGL_OPENGL40, () -> {
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 0);
-        });
-        RENDER_CONFIGS.put(AppSettings.LWJGL_OPENGL41, () -> {
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 1);
-        });
-        RENDER_CONFIGS.put(AppSettings.LWJGL_OPENGL42, () -> {
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 2);
-        });
-        RENDER_CONFIGS.put(AppSettings.LWJGL_OPENGL43, () -> {
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
-        });
-        RENDER_CONFIGS.put(AppSettings.LWJGL_OPENGL44, () -> {
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 4);
-        });
-        RENDER_CONFIGS.put(AppSettings.LWJGL_OPENGL45, () -> {
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 5);
-        });
+        RENDER_CONFIGS.put(
+            AppSettings.LWJGL_OPENGL30,
+            () -> {
+                // Based on GLFW docs for OpenGL version below 3.2,
+                // GLFW_OPENGL_ANY_PROFILE must be used.
+                glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_ANY_PROFILE);
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 0);
+            }
+        );
+        RENDER_CONFIGS.put(
+            AppSettings.LWJGL_OPENGL31,
+            () -> {
+                // Based on GLFW docs for OpenGL version below 3.2,
+                // GLFW_OPENGL_ANY_PROFILE must be used.
+                glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_ANY_PROFILE);
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 1);
+            }
+        );
+        RENDER_CONFIGS.put(
+            AppSettings.LWJGL_OPENGL32,
+            () -> {
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 2);
+            }
+        );
+        RENDER_CONFIGS.put(
+            AppSettings.LWJGL_OPENGL33,
+            () -> {
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
+            }
+        );
+        RENDER_CONFIGS.put(
+            AppSettings.LWJGL_OPENGL40,
+            () -> {
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 0);
+            }
+        );
+        RENDER_CONFIGS.put(
+            AppSettings.LWJGL_OPENGL41,
+            () -> {
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 1);
+            }
+        );
+        RENDER_CONFIGS.put(
+            AppSettings.LWJGL_OPENGL42,
+            () -> {
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 2);
+            }
+        );
+        RENDER_CONFIGS.put(
+            AppSettings.LWJGL_OPENGL43,
+            () -> {
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
+            }
+        );
+        RENDER_CONFIGS.put(
+            AppSettings.LWJGL_OPENGL44,
+            () -> {
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 4);
+            }
+        );
+        RENDER_CONFIGS.put(
+            AppSettings.LWJGL_OPENGL45,
+            () -> {
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
+                glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 5);
+            }
+        );
     }
 
     protected final AtomicBoolean needClose = new AtomicBoolean(false);
     protected final AtomicBoolean needRestart = new AtomicBoolean(false);
 
     private final JmeContext.Type type;
-    private final SafeArrayList<WindowSizeListener> windowSizeListeners = new SafeArrayList<>(WindowSizeListener.class);
+    private final SafeArrayList<WindowSizeListener> windowSizeListeners = new SafeArrayList<>(
+        WindowSizeListener.class
+    );
 
     private GLFWErrorCallback errorCallback;
     private GLFWWindowSizeCallback windowSizeCallback;
@@ -148,6 +183,7 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
 
     private Thread mainThread;
 
+    private long monitor = NULL;
     private long window = NULL;
     private int frameRateLimit = -1;
 
@@ -165,7 +201,6 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
     private final Vector2f oldScale = new Vector2f(1, 1);
 
     public LwjglWindow(final JmeContext.Type type) {
-
         if (!SUPPORTED_TYPES.contains(type)) {
             throw new IllegalArgumentException("Unsupported type '" + type.name() + "' provided");
         }
@@ -229,13 +264,16 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
      * @param settings the settings to apply when creating the context.
      */
     protected void createContext(final AppSettings settings) {
-        glfwSetErrorCallback(errorCallback = new GLFWErrorCallback() {
-            @Override
-            public void invoke(int error, long description) {
-                final String message = GLFWErrorCallback.getDescription(description);
-                listener.handleError(message, new Exception(message));
-            }
-        });
+        glfwSetErrorCallback(
+            errorCallback =
+                new GLFWErrorCallback() {
+                    @Override
+                    public void invoke(int error, long description) {
+                        final String message = GLFWErrorCallback.getDescription(description);
+                        listener.handleError(message, new Exception(message));
+                    }
+                }
+        );
 
         if (!glfwInit()) {
             throw new IllegalStateException("Unable to initialize GLFW");
@@ -248,12 +286,18 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
         glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
         glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
 
-        RENDER_CONFIGS.computeIfAbsent(renderer, s -> () -> {
-            glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_FALSE);
-            glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_ANY_PROFILE);
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 2);
-            glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 0);
-        }).run();
+        RENDER_CONFIGS
+            .computeIfAbsent(
+                renderer,
+                s ->
+                    () -> {
+                        glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_FALSE);
+                        glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_ANY_PROFILE);
+                        glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 2);
+                        glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 0);
+                    }
+            )
+            .run();
 
         if (settings.getBoolean("RendererDebug")) {
             glfwWindowHint(GLFW_OPENGL_DEBUG_CONTEXT, GLFW_TRUE);
@@ -269,8 +313,14 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
         glfwWindowHint(GLFW_STENCIL_BITS, settings.getStencilBits());
         glfwWindowHint(GLFW_SAMPLES, settings.getSamples());
         glfwWindowHint(GLFW_STEREO, settings.useStereo3D() ? GLFW_TRUE : GLFW_FALSE);
-        glfwWindowHint(GLFW_REFRESH_RATE, settings.getFrequency()<=0?GLFW_DONT_CARE:settings.getFrequency());
-        glfwWindowHint(GLFW_COCOA_RETINA_FRAMEBUFFER, settings.isUseRetinaFrameBuffer() ? GLFW_TRUE : GLFW_FALSE);
+        glfwWindowHint(
+            GLFW_REFRESH_RATE,
+            settings.getFrequency() <= 0 ? GLFW_DONT_CARE : settings.getFrequency()
+        );
+        glfwWindowHint(
+            GLFW_COCOA_RETINA_FRAMEBUFFER,
+            settings.isUseRetinaFrameBuffer() ? GLFW_TRUE : GLFW_FALSE
+        );
 
         if (settings.getBitsPerPixel() == 24) {
             glfwWindowHint(GLFW_RED_BITS, 8);
@@ -284,51 +334,65 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
 
         glfwWindowHint(GLFW_ALPHA_BITS, settings.getAlphaBits());
 
-        // TODO: Add support for monitor selection
-        long monitor = NULL;
+        //        long monitor = NULL;
 
+        /**
+         * Let's grab the display selected, if not found it will return
+         * primaryMonitor. if not full screen just use primary display data.
+         */
         if (settings.isFullscreen()) {
+            monitor = getDisplay(settings.getDisplay());
+        } else {
             monitor = glfwGetPrimaryMonitor();
         }
 
-        final GLFWVidMode videoMode = glfwGetVideoMode(glfwGetPrimaryMonitor());
+        final GLFWVidMode videoMode = glfwGetVideoMode(monitor);
         int requestWidth = settings.getWindowWidth();
         int requestHeight = settings.getWindowHeight();
         if (requestWidth <= 0 || requestHeight <= 0) {
             requestWidth = videoMode.width();
             requestHeight = videoMode.height();
         }
-        window = glfwCreateWindow(requestWidth, requestHeight, settings.getTitle(), monitor, NULL);
+
+        // Lets use the monitor selected from AppSettings if FullScreen is
+        // set.
+        if (settings.isFullscreen()) window =
+            glfwCreateWindow(requestWidth, requestHeight, settings.getTitle(), monitor, NULL); else window =
+            glfwCreateWindow(requestWidth, requestHeight, settings.getTitle(), NULL, NULL);
+
         if (window == NULL) {
             throw new RuntimeException("Failed to create the GLFW window");
         }
 
-        glfwSetWindowFocusCallback(window, windowFocusCallback = new GLFWWindowFocusCallback() {
-
-            @Override
-            public void invoke(final long window, final boolean focus) {
-                if (wasActive != focus) {
-                    if (!wasActive) {
-                        listener.gainFocus();
-                        timer.reset();
-                    } else {
-                        listener.loseFocus();
+        glfwSetWindowFocusCallback(
+            window,
+            windowFocusCallback =
+                new GLFWWindowFocusCallback() {
+                    @Override
+                    public void invoke(final long window, final boolean focus) {
+                        if (wasActive != focus) {
+                            if (!wasActive) {
+                                listener.gainFocus();
+                                timer.reset();
+                            } else {
+                                listener.loseFocus();
+                            }
+                            wasActive = !wasActive;
+                        }
                     }
-                    wasActive = !wasActive;
                 }
-            }
-        });
+        );
 
         if (!settings.isFullscreen()) {
             if (settings.getCenterWindow()) {
                 // Center the window
-                glfwSetWindowPos(window,
-                        (videoMode.width() - requestWidth) / 2,
-                        (videoMode.height() - requestHeight) / 2);
+                glfwSetWindowPos(
+                    window,
+                    (videoMode.width() - requestWidth) / 2,
+                    (videoMode.height() - requestHeight) / 2
+                );
             } else {
-                glfwSetWindowPos(window,
-                        settings.getWindowXPosition(),
-                        settings.getWindowYPosition());
+                glfwSetWindowPos(window, settings.getWindowXPosition(), settings.getWindowYPosition());
             }
         }
 
@@ -348,24 +412,30 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
         // HACK: the framebuffer seems to be initialized with the wrong size
         // on some HiDPI platforms until glfwPollEvents is called 2 or 3 times
         for (int i = 0; i < 4; i++) glfwPollEvents();
-        
-        // Windows resize callback
-        glfwSetWindowSizeCallback(window, windowSizeCallback = new GLFWWindowSizeCallback() {
 
-            @Override
-            public void invoke(final long window, final int width, final int height) {
-                updateSizes();
-            }
-        });
+        // Windows resize callback
+        glfwSetWindowSizeCallback(
+            window,
+            windowSizeCallback =
+                new GLFWWindowSizeCallback() {
+                    @Override
+                    public void invoke(final long window, final int width, final int height) {
+                        updateSizes();
+                    }
+                }
+        );
 
         // Add a framebuffer resize callback which delegates to the listener
-        glfwSetFramebufferSizeCallback(window, framebufferSizeCallback = new GLFWFramebufferSizeCallback() {
-
-            @Override
-            public void invoke(final long window, final int width, final int height) {
-                updateSizes();
-            }
-        });
+        glfwSetFramebufferSizeCallback(
+            window,
+            framebufferSizeCallback =
+                new GLFWFramebufferSizeCallback() {
+                    @Override
+                    public void invoke(final long window, final int width, final int height) {
+                        updateSizes();
+                    }
+                }
+        );
 
         allowSwapBuffers = settings.isSwapBuffers();
 
@@ -383,8 +453,7 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
         glfwGetWindowSize(window, width, height);
         int windowWidth = width[0] < 1 ? 1 : width[0];
         int windowHeight = height[0] < 1 ? 1 : height[0];
-        if (settings.getWindowWidth() != windowWidth
-                || settings.getWindowHeight() != windowHeight) {
+        if (settings.getWindowWidth() != windowWidth || settings.getWindowHeight() != windowHeight) {
             settings.setWindowSize(windowWidth, windowHeight);
             for (WindowSizeListener wsListener : windowSizeListeners.getArray()) {
                 wsListener.onWindowSizeChanged(windowWidth, windowHeight);
@@ -394,8 +463,7 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
         glfwGetFramebufferSize(window, width, height);
         int framebufferWidth = width[0];
         int framebufferHeight = height[0];
-        if (framebufferWidth != oldFramebufferWidth
-                || framebufferHeight != oldFramebufferHeight) {
+        if (framebufferWidth != oldFramebufferWidth || framebufferHeight != oldFramebufferHeight) {
             settings.setResolution(framebufferWidth, framebufferHeight);
             listener.reshape(framebufferWidth, framebufferHeight);
 
@@ -422,14 +490,12 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
      * @param settings settings for getting the icons
      */
     protected void setWindowIcon(final AppSettings settings) {
-
         final Object[] icons = settings.getIcons();
         if (icons == null) return;
 
         final GLFWImage[] images = imagesToGLFWImages(icons);
 
         try (final GLFWImage.Buffer iconSet = GLFWImage.malloc(images.length)) {
-
             for (int i = images.length - 1; i >= 0; i--) {
                 final GLFWImage image = images[i];
                 iconSet.put(i, image);
@@ -443,7 +509,6 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
      * Convert array of images to array of {@link GLFWImage}.
      */
     private GLFWImage[] imagesToGLFWImages(final Object[] images) {
-
         final GLFWImage[] out = new GLFWImage[images.length];
 
         for (int i = 0; i < images.length; i++) {
@@ -458,10 +523,12 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
      * Convert the {@link BufferedImage} to the {@link GLFWImage}.
      */
     private GLFWImage imageToGLFWImage(BufferedImage image) {
-
         if (image.getType() != BufferedImage.TYPE_INT_ARGB_PRE) {
-
-            final BufferedImage convertedImage = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB_PRE);
+            final BufferedImage convertedImage = new BufferedImage(
+                image.getWidth(),
+                image.getHeight(),
+                BufferedImage.TYPE_INT_ARGB_PRE
+            );
             final Graphics2D graphics = convertedImage.createGraphics();
 
             final int targetWidth = image.getWidth();
@@ -503,7 +570,6 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
             }
 
             if (errorCallback != null) {
-
                 // We need to specifically set this to null as we might set a new callback before we reinit GLFW
                 glfwSetErrorCallback(null);
 
@@ -530,7 +596,6 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
                 glfwDestroyWindow(window);
                 window = NULL;
             }
-
         } catch (final Exception ex) {
             listener.handleError("Failed to destroy context", ex);
         }
@@ -558,7 +623,6 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
                 waitFor(true);
             }
         }
-
     }
 
     /**
@@ -570,14 +634,16 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
         try {
             if (!JmeSystem.isLowPermissions()) {
                 // Enable uncaught exception handler only for current thread
-                Thread.currentThread().setUncaughtExceptionHandler((thread, thrown) -> {
-                    listener.handleError("Uncaught exception thrown in " + thread.toString(), thrown);
-                    if (needClose.get()) {
-                        // listener.handleError() has requested the
-                        // context to close. Satisfy request.
-                        deinitInThread();
-                    }
-                });
+                Thread
+                    .currentThread()
+                    .setUncaughtExceptionHandler((thread, thrown) -> {
+                        listener.handleError("Uncaught exception thrown in " + thread.toString(), thrown);
+                        if (needClose.get()) {
+                            // listener.handleError() has requested the
+                            // context to close. Satisfy request.
+                            deinitInThread();
+                        }
+                    });
             }
 
             timer = new NanoTimer();
@@ -611,7 +677,6 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
         return true;
     }
 
-
     /**
      * execute one iteration of the render loop in the OpenGL thread
      */
@@ -625,7 +690,6 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
             throw new IllegalStateException();
         }
 
-
         listener.update();
 
         // All this does is call glfwSwapBuffers().
@@ -703,8 +767,9 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
     @Override
     public void run() {
         if (listener == null) {
-            throw new IllegalStateException("SystemListener is not set on context!"
-                    + "Must set with JmeContext.setSystemListener().");
+            throw new IllegalStateException(
+                "SystemListener is not set on context!" + "Must set with JmeContext.setSystemListener()."
+            );
         }
 
         LOGGER.log(Level.FINE, "Using LWJGL {0}", Version.getVersion());
@@ -715,7 +780,6 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
         }
 
         while (true) {
-
             runLoop();
 
             if (needClose.get()) {
@@ -855,4 +919,80 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
         int result = height[0];
         return result;
     }
+
+    /**
+     * Returns the Primary Monitor position number from the list of monitors
+     * returned by glfwGetPrimaryMonitor().  If primary monitor not found
+     * it will return -1 and report the error.
+     *
+     * @return returns the Primary Monitor Position.
+     */
+    @Override
+    public int getPrimaryDisplay() {
+        long prim = glfwGetPrimaryMonitor();
+        Displays monitors = getDisplays();
+        for (int i = 0; i < monitors.size(); i++) {
+            long monitorI = monitors.get(i).displayID;
+            if (monitorI == prim) return i;
+        }
+
+        LOGGER.log(Level.SEVERE, "Couldn't locate Primary Monitor in the list of Monitors.");
+        return -1;
+    }
+
+    /**
+     * This routines return the display ID by position in an array of display returned
+     * by glfwGetMonitors().
+     *
+     * @param pos  the position of the display in the list of displays returned.
+     * @return return the displayID if found otherwise return Primary display
+     */
+    private long getDisplay(int pos) {
+        Displays displays = getDisplays();
+        if (pos < displays.size()) return displays.get(pos).displayID;
+
+        LOGGER.log(
+            Level.SEVERE,
+            "Couldn't locate Display requested in the list of Displays. pos:" +
+            pos +
+            " size: " +
+            displays.size()
+        );
+        return glfwGetPrimaryMonitor();
+    }
+
+    /**
+     * This returns an arraylist of all the Display returned by OpenGL get Monitor
+     * call.  It will also has some limited information about each display, like:
+     * width, height and refresh rate.
+     *
+     * @return returns an ArrayList of all Display returned by glfwGetMonitors()
+     */
+
+    @Override
+    public Displays getDisplays() {
+        PointerBuffer displays = glfwGetMonitors();
+        long primary = glfwGetPrimaryMonitor();
+        Displays displayList = new Displays();
+
+        for (int i = 0; i < displays.limit(); i++) {
+            long monitorI = displays.get(i);
+            int monPos = displayList.addNewMonitor(monitorI);
+            //lets check if this display is the primary display. If use mark it as such.
+            if (primary == monitorI) displayList.setPrimaryDisplay(monPos);
+
+            final GLFWVidMode modes = glfwGetVideoMode(monitorI);
+            String name = glfwGetMonitorName(monitorI);
+
+            int width = modes.width();
+            int height = modes.height();
+            int rate = modes.refreshRate();
+            displayList.setInfo(monPos, name, width, height, rate);
+            LOGGER.log(
+                Level.INFO,
+                "Display id: " + monitorI + " Resolution: " + width + " x " + height + " @ " + rate
+            );
+        }
+        return displayList;
+    }
 }

+ 50 - 0
jme3-screenshot-tests/README.md

@@ -0,0 +1,50 @@
+# jme3-screenshot-tests
+
+This module contains tests that compare screenshots of the JME3 test applications to reference images. The tests are run using 
+the following command:
+
+```
+ ./gradlew :jme3-screenshot-test:screenshotTest
+```
+
+This will create a report in `jme3-screenshot-test/build/reports/ScreenshotDiffReport.html` that shows the differences between the reference images and the screenshots taken during the test run. Note that this is an ExtentReport. 
+
+This is most reliable when run on the CI server. The report can be downloaded from the artifacts section of the pipeline (once the full pipeline has completed). If you go into
+the Actions tab (on GitHub) and find your pipeline you can download the report from the Artifacts section. It will be called screenshot-test-report.
+
+## Machine variability
+
+It is important to be aware that the tests are sensitive to machine variability. Different GPUs may produce subtly different pixel outputs
+(that look identical to a human user). The tests are run on a specific machine and the reference images are generated on that machine. If the tests are run on a different machine, the images may not match the reference images and this is "fine". If you run these on your local machine compare the differences by eye in the report, don't wory about failing tests.
+
+## Parameterised tests
+
+By default, the tests use the class and method name to produce the screenshot image name. E.g. org.jmonkeyengine.screenshottests.effects.TestExplosionEffect.testExplosionEffect_f15.png is the testExplosionEffect test at frame 15. If you are using parameterised tests this won't work (as all the tests have the same function name). In this case you should specify the image name (including whatever parameterised information to make it unique). E.g.
+
+```
+    screenshotTest(
+        ....
+    ).setFramesToTakeScreenshotsOn(45)
+    .setBaseImageFileName("some_unique_name_" + theParameterGivenToTest)
+    .run();
+)
+```
+
+## Non-deterministic (and known bad) tests
+
+By default, screenshot variability will cause the pipeline to fail. If a test is non-deterministic (e.g. includes randomness) or 
+is a known accepted failure (that will be fixed "at some point" but not now) that can be non-desirable. In that case you can 
+change the behaviour of the test such that these are marked as warnings in the generated report but don't fail the test
+
+```
+    screenshotTest(
+        ....
+    ).setFramesToTakeScreenshotsOn(45)
+    .setTestType(TestType.NON_DETERMINISTIC)
+    .run();
+)
+```
+
+## Accepting new images
+
+It may be the case that a change makes an improvement to the library (or the test is entirely new) and the new image should be accepted as the new reference image. To do this, copy the new image to the `src/test/resources` directory. The new image can be found in the `build/changed-images` directory, however it is very important that the image come from the reference machine. This can be obtained from the CI server. The job runs only if there is an active pull request (to one of the mainline branches; e.g. master or 3.7). If you go into the Actions tab and find your pipeline you can download the report and changed images from the Artifacts section.

+ 38 - 0
jme3-screenshot-tests/build.gradle

@@ -0,0 +1,38 @@
+plugins {
+    id 'java'
+}
+
+repositories {
+    mavenCentral()
+}
+
+dependencies {
+    implementation project(':jme3-desktop')
+    implementation project(':jme3-core')
+    implementation project(':jme3-effects')
+    implementation project(':jme3-terrain')
+    implementation project(':jme3-lwjgl3')
+    implementation project(':jme3-plugins')
+
+    implementation 'com.aventstack:extentreports:5.1.1'
+    implementation platform('org.junit:junit-bom:5.9.1')
+    implementation 'org.junit.jupiter:junit-jupiter'
+    testRuntimeOnly project(':jme3-testdata')
+}
+
+tasks.register("screenshotTest", Test) {
+    useJUnitPlatform{
+        filter{
+            includeTags 'integration'
+        }
+    }
+}
+
+
+test {
+    useJUnitPlatform{
+        filter{
+            excludeTags 'integration'
+        }
+    }
+}

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

@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.AppState;
+import com.jme3.app.state.VideoRecorderAppState;
+import com.jme3.math.ColorRGBA;
+
+/**
+ * The app used for the tests. AppState(s) are used to inject the actual test code.
+ * @author Richard Tingle (aka richtea)
+ */
+public class App extends SimpleApplication {
+
+    public App(AppState... initialStates){
+        super(initialStates);
+    }
+
+    @Override
+    public void simpleInitApp(){
+        getViewPort().setBackgroundColor(ColorRGBA.Black);
+        setTimer(new VideoRecorderAppState.IsoTimer(60));
+    }
+
+}

+ 105 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ExtentReportExtension.java

@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.aventstack.extentreports.ExtentReports;
+import com.aventstack.extentreports.ExtentTest;
+import com.aventstack.extentreports.reporter.ExtentSparkReporter;
+import com.aventstack.extentreports.reporter.configuration.Theme;
+import org.junit.jupiter.api.extension.AfterAllCallback;
+import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.TestWatcher;
+
+import java.util.Optional;
+
+/**
+ * This creates the Extent report and manages the test lifecycle
+ *
+ * @author Richard Tingle (aka richtea)
+ */
+public class ExtentReportExtension implements BeforeAllCallback, AfterAllCallback, TestWatcher, BeforeTestExecutionCallback{
+    private static ExtentReports extent;
+    private static final ThreadLocal<ExtentTest> test = new ThreadLocal<>();
+
+    @Override
+    public void beforeAll(ExtensionContext context) {
+        if(extent==null){
+            ExtentSparkReporter spark = new ExtentSparkReporter("build/reports/ScreenshotDiffReport.html");
+            spark.config().setTheme(Theme.STANDARD);
+            spark.config().setDocumentTitle("Screenshot Test Report");
+            spark.config().setReportName("Screenshot Test Report");
+            extent = new ExtentReports();
+            extent.attachReporter(spark);
+        }
+    }
+
+    @Override
+    public void afterAll(ExtensionContext context) {
+        /*
+        * this writes the entire report after each test class. This sucks but I don't think there is
+        * anywhere else I can hook into the lifecycle of the end of all tests to write the report.
+        */
+        extent.flush();
+    }
+
+    @Override
+    public void testSuccessful(ExtensionContext context) {
+        getCurrentTest().pass("Test passed");
+    }
+
+    @Override
+    public void testFailed(ExtensionContext context, Throwable cause) {
+        getCurrentTest().fail(cause);
+    }
+
+    @Override
+    public void testAborted(ExtensionContext context, Throwable cause) {
+        getCurrentTest().skip("Test aborted " + cause.toString());
+    }
+
+    @Override
+    public void testDisabled(ExtensionContext context, Optional<String> reason) {
+        getCurrentTest().skip("Test disabled: " + reason.orElse("No reason"));
+    }
+
+    @Override
+    public void beforeTestExecution(ExtensionContext context) {
+        String testName = context.getDisplayName();
+        test.set(extent.createTest(testName));
+    }
+
+    public static ExtentTest getCurrentTest() {
+        return test.get();
+    }
+}

+ 66 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/PixelSamenessDegree.java

@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.jme3.math.ColorRGBA;
+
+/**
+ * @author Richard Tingle (aka richtea)
+ */
+public enum PixelSamenessDegree{
+    SAME(1, null),
+    NEGLIGIBLY_DIFFERENT(1, ColorRGBA.Green),
+    SUBTLY_DIFFERENT(10, ColorRGBA.Blue),
+
+    MEDIUMLY_DIFFERENT(20, ColorRGBA.Yellow),
+
+    VERY_DIFFERENT(60,ColorRGBA.Orange),
+
+    EXTREMELY_DIFFERENT(100,ColorRGBA.Red);
+
+    private final int maximumAllowedDifference;
+
+    private final ColorRGBA colorInDebugImage;
+
+    PixelSamenessDegree(int maximumAllowedDifference, ColorRGBA colorInDebugImage){
+        this.colorInDebugImage = colorInDebugImage;
+        this.maximumAllowedDifference = maximumAllowedDifference;
+    }
+
+    public ColorRGBA getColorInDebugImage(){
+        return colorInDebugImage;
+    }
+
+    public int getMaximumAllowedDifference(){
+        return maximumAllowedDifference;
+    }
+}

+ 331 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotNoInputAppState.java

@@ -0,0 +1,331 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.jme3.app.Application;
+import com.jme3.app.state.AbstractAppState;
+import com.jme3.app.state.AppStateManager;
+import com.jme3.input.controls.ActionListener;
+import com.jme3.post.SceneProcessor;
+import com.jme3.profile.AppProfiler;
+import com.jme3.renderer.Camera;
+import com.jme3.renderer.RenderManager;
+import com.jme3.renderer.Renderer;
+import com.jme3.renderer.ViewPort;
+import com.jme3.renderer.queue.RenderQueue;
+import com.jme3.system.JmeSystem;
+import com.jme3.texture.FrameBuffer;
+import com.jme3.util.BufferUtils;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * This is more or less the same as ScreenshotAppState but without the keyboard input
+ * (because in a headless environment, there is no keyboard and trying to configure it caused
+ * errors).
+ *
+ * @author Richard Tingle (aka richtea)
+ *
+ */
+public class ScreenshotNoInputAppState extends AbstractAppState implements ActionListener, SceneProcessor {
+
+    private static final Logger logger = Logger.getLogger(ScreenshotNoInputAppState.class.getName());
+    private String filePath;
+    private boolean capture = false;
+    private boolean numbered = true;
+    private Renderer renderer;
+    private RenderManager rm;
+    private ByteBuffer outBuf;
+    private String shotName;
+    private long shotIndex = 0;
+    private int width, height;
+
+    /**
+     * ViewPort to which the SceneProcessor is attached
+     */
+    private ViewPort last;
+
+    /**
+     * Using this constructor, the screenshot files will be written sequentially to the system
+     * default storage folder.
+     */
+    public ScreenshotNoInputAppState() {
+        this(null);
+    }
+
+    /**
+     * This constructor allows you to specify the output file path of the screenshot.
+     * Include the separator at the end of the path.
+     * Use an empty string to use the application folder. Use NULL to use the system
+     * default storage folder.
+     * @param filePath The screenshot file path to use. Include the separator at the end of the path.
+     */
+    public ScreenshotNoInputAppState(String filePath) {
+        this.filePath = filePath;
+    }
+
+    /**
+     * This constructor allows you to specify the output file path of the screenshot.
+     * Include the separator at the end of the path.
+     * Use an empty string to use the application folder. Use NULL to use the system
+     * default storage folder.
+     * @param filePath The screenshot file path to use. Include the separator at the end of the path.
+     * @param fileName The name of the file to save the screenshot as.
+     */
+    public ScreenshotNoInputAppState(String filePath, String fileName) {
+        this.filePath = filePath;
+        this.shotName = fileName;
+    }
+
+    /**
+     * This constructor allows you to specify the output file path of the screenshot and
+     * a base index for the shot index.
+     * Include the separator at the end of the path.
+     * Use an empty string to use the application folder. Use NULL to use the system
+     * default storage folder.
+     * @param filePath The screenshot file path to use. Include the separator at the end of the path.
+     * @param shotIndex The base index for screenshots.  The first screenshot will have
+     *     shotIndex + 1 appended, the next shotIndex + 2, and so on.
+     */
+    public ScreenshotNoInputAppState(String filePath, long shotIndex) {
+        this.filePath = filePath;
+        this.shotIndex = shotIndex;
+    }
+
+    /**
+     * This constructor allows you to specify the output file path of the screenshot and
+     * a base index for the shot index.
+     * Include the separator at the end of the path.
+     * Use an empty string to use the application folder. Use NULL to use the system
+     * default storage folder.
+     * @param filePath The screenshot file path to use. Include the separator at the end of the path.
+     * @param fileName The name of the file to save the screenshot as.
+     * @param shotIndex The base index for screenshots.  The first screenshot will have
+     *     shotIndex + 1 appended, the next shotIndex + 2, and so on.
+     */
+    public ScreenshotNoInputAppState(String filePath, String fileName, long shotIndex) {
+        this.filePath = filePath;
+        this.shotName = fileName;
+        this.shotIndex = shotIndex;
+    }
+
+    /**
+     * Set the file path to store the screenshot.
+     * Include the separator at the end of the path.
+     * Use an empty string to use the application folder. Use NULL to use the system
+     * default storage folder.
+     * @param filePath File path to use to store the screenshot. Include the separator at the end of the path.
+     */
+    public void setFilePath(String filePath) {
+        this.filePath = filePath;
+    }
+
+    /**
+     * Set the file name of the screenshot.
+     * @param fileName File name to save the screenshot as.
+     */
+    public void setFileName(String fileName) {
+        this.shotName = fileName;
+    }
+
+    /**
+     * Sets the base index that will used for subsequent screenshots.
+     *
+     * @param index the desired base index
+     */
+    public void setShotIndex(long index) {
+        this.shotIndex = index;
+    }
+
+    /**
+     * Sets if the filename should be appended with a number representing the
+     * current sequence.
+     * @param numberedWanted If numbering is wanted.
+     */
+    public void setIsNumbered(boolean numberedWanted) {
+        this.numbered = numberedWanted;
+    }
+
+    @Override
+    public void initialize(AppStateManager stateManager, Application app) {
+        if (!super.isInitialized()) {
+            List<ViewPort> vps = app.getRenderManager().getPostViews();
+            last = vps.get(vps.size() - 1);
+            last.addProcessor(this);
+
+            if (shotName == null) {
+                shotName = app.getClass().getSimpleName();
+            }
+        }
+
+        super.initialize(stateManager, app);
+    }
+
+    /**
+     * Clean up this AppState during the first update after it gets detached.
+     * <p>
+     * Because each ScreenshotAppState is also a SceneProcessor (in addition to
+     * being an AppState) this method is also invoked when the SceneProcessor
+     * get removed from its ViewPort, leading to an indirect recursion:
+     * <ol><li>AppStateManager invokes ScreenshotAppState.cleanup()</li>
+     * <li>cleanup() invokes ViewPort.removeProcessor()</li>
+     * <li>removeProcessor() invokes ScreenshotAppState.cleanup()</li>
+     * <li>... and so on.</li>
+     * </ol>
+     * <p>
+     * In order to break this recursion, this method only removes the
+     * SceneProcessor if it has not previously been removed.
+     * <p>
+     * A better design would have the AppState and SceneProcessor be 2 distinct
+     * objects, but doing so now might break applications that rely on them
+     * being a single object.
+     */
+    @Override
+    public void cleanup() {
+        ViewPort viewPort = last;
+        if (viewPort != null) {
+            last = null;
+            viewPort.removeProcessor(this); // XXX indirect recursion!
+        }
+
+        super.cleanup();
+    }
+
+    @Override
+    public void onAction(String name, boolean value, float tpf) {
+        if (value) {
+            capture = true;
+        }
+    }
+
+    public void takeScreenshot() {
+        capture = true;
+    }
+
+    @Override
+    public void initialize(RenderManager rm, ViewPort vp) {
+        renderer = rm.getRenderer();
+        this.rm = rm;
+        reshape(vp, vp.getCamera().getWidth(), vp.getCamera().getHeight());
+    }
+
+    @Override
+    public boolean isInitialized() {
+        return super.isInitialized() && renderer != null;
+    }
+
+    @Override
+    public void reshape(ViewPort vp, int w, int h) {
+        outBuf = BufferUtils.createByteBuffer(w * h * 4);
+        width = w;
+        height = h;
+    }
+
+    @Override
+    public void preFrame(float tpf) {
+        // do nothing
+    }
+
+    @Override
+    public void postQueue(RenderQueue rq) {
+        // do nothing
+    }
+
+    @Override
+    public void postFrame(FrameBuffer out) {
+        if (capture) {
+            capture = false;
+
+            Camera curCamera = rm.getCurrentCamera();
+            int viewX = (int) (curCamera.getViewPortLeft() * curCamera.getWidth());
+            int viewY = (int) (curCamera.getViewPortBottom() * curCamera.getHeight());
+            int viewWidth = (int) ((curCamera.getViewPortRight() - curCamera.getViewPortLeft()) * curCamera.getWidth());
+            int viewHeight = (int) ((curCamera.getViewPortTop() - curCamera.getViewPortBottom()) * curCamera.getHeight());
+
+            renderer.setViewPort(0, 0, width, height);
+            renderer.readFrameBuffer(out, outBuf);
+            renderer.setViewPort(viewX, viewY, viewWidth, viewHeight);
+
+            File file;
+            String filename;
+            if (numbered) {
+                shotIndex++;
+                filename = shotName + shotIndex;
+            } else {
+                filename = shotName;
+            }
+
+            if (filePath == null) {
+                file = new File(JmeSystem.getStorageFolder() + File.separator + filename + ".png").getAbsoluteFile();
+            } else {
+                file = new File(filePath + filename + ".png").getAbsoluteFile();
+            }
+
+            if (logger.isLoggable(Level.FINE)) {
+                logger.log(Level.FINE, "Saving ScreenShot to: {0}", file.getAbsolutePath());
+            }
+
+            try {
+                writeImageFile(file);
+            } catch (IOException ex) {
+                logger.log(Level.SEVERE, "Error while saving screenshot", ex);
+            }
+        }
+    }
+
+    @Override
+    public void setProfiler(AppProfiler profiler) {
+        // not implemented
+    }
+
+    /**
+     * Called by postFrame() once the screen has been captured to outBuf.
+     *
+     * @param file the output file
+     * @throws IOException if an I/O error occurs
+     */
+    protected void writeImageFile(File file) throws IOException {
+        OutputStream outStream = new FileOutputStream(file);
+        try {
+            JmeSystem.writeImageFile(outStream, "png", outBuf, width, height);
+        } finally {
+            outStream.close();
+        }
+    }
+}

+ 120 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTest.java

@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.jme3.app.state.AppState;
+import com.jme3.system.AppSettings;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * This is how a test is configured and started. It uses a fluent API.
+ *
+ * @author Richard Tingle (aka richtea)
+ */
+public class ScreenshotTest{
+
+    TestType testType = TestType.MUST_PASS;
+
+    AppState[] states;
+
+    List<Integer> framesToTakeScreenshotsOn = new ArrayList<>();
+
+    TestResolution resolution = new TestResolution(500, 400);
+
+    String baseImageFileName = null;
+
+    public ScreenshotTest(AppState... initialStates){
+        states = initialStates;
+        framesToTakeScreenshotsOn.add(1); //default behaviour is to take a screenshot on the first frame
+    }
+
+    /**
+     * Sets the frames to take screenshots on. Frames are at a hard coded 60 FPS (from JME's perspective, clock time may vary).
+     */
+    public ScreenshotTest setFramesToTakeScreenshotsOn(Integer... frames){
+        framesToTakeScreenshotsOn.clear();
+        framesToTakeScreenshotsOn.addAll(Arrays.asList(frames));
+        return this;
+    }
+
+    /**
+     * Sets the test type (i.e. what the pass/fail rules are for the test
+     */
+    public ScreenshotTest setTestType(TestType testType){
+        this.testType = testType;
+        return this;
+    }
+
+    public ScreenshotTest setTestResolution(TestResolution resolution){
+        this.resolution = resolution;
+        return this;
+    }
+
+    /**
+     * Sets the file name to be used (as the first part) of saved images in both the resources directory and
+     * the generated image. Note that you only have to call this if you want to override the default behaviour which is
+     * to use the calling class and method name, like org.jmonkeyengine.screenshottests.water.TestPostWater.testPostWater
+     */
+    public ScreenshotTest setBaseImageFileName(String baseImageFileName){
+        this.baseImageFileName = baseImageFileName;
+        return this;
+    }
+
+    public void run(){
+        AppSettings settings = new AppSettings(true);
+        settings.setResolution(resolution.getWidth(), resolution.getHeight());
+        settings.setAudioRenderer(null); // Disable audio (for headless)
+        settings.setUseInput(false); //while it will run with inputs on it causes non-fatal errors.
+
+        String imageFilePrefix = baseImageFileName == null ? calculateImageFilePrefix() : baseImageFileName;
+
+        TestDriver.bootAppForTest(testType,settings,imageFilePrefix, framesToTakeScreenshotsOn, states);
+    }
+
+
+    private String calculateImageFilePrefix(){
+        StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
+
+        // The element at index 2 is the caller of this method, so at 3 should be the test class
+        if (stackTrace.length > 3) {
+            StackTraceElement caller = stackTrace[3];
+            return caller.getClassName() + "." + caller.getMethodName();
+        } else {
+            throw new RuntimeException("Caller information is not available.");
+        }
+    }
+
+
+}

+ 56 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTestBase.java

@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.jme3.app.state.AppState;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * This is the base class for all screenshot tests
+ *
+ * @author Richard Tingle (aka richtea)
+ */
+@ExtendWith(ExtentReportExtension.class)
+@Tag("integration")
+public abstract class ScreenshotTestBase{
+
+    /**
+     * Initialises a screenshot test. The resulting object should be configured (if neccessary) and then started
+     * by calling {@link ScreenshotTest#run()}.
+     * @param initialStates
+     * @return
+     */
+    public ScreenshotTest screenshotTest(AppState... initialStates){
+        return new ScreenshotTest(initialStates);
+    }
+}

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

@@ -0,0 +1,458 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.aventstack.extentreports.ExtentTest;
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.AppState;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.math.FastMath;
+import com.jme3.system.AppSettings;
+import com.jme3.system.JmeContext;
+
+import javax.imageio.IIOImage;
+import javax.imageio.ImageIO;
+import javax.imageio.ImageWriteParam;
+import javax.imageio.ImageWriter;
+import javax.imageio.stream.ImageOutputStream;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * The test driver injects the screenshot taking into the application lifecycle, pauses the main test thread until the
+ * screenshots have been taken and then compares the screenshots to the expected images.
+ *
+ * @author Richard Tingle (aka richtea)
+ *
+ */
+public class TestDriver extends BaseAppState{
+
+    public static final String IMAGES_ARE_DIFFERENT = "Images are different.";
+
+    public static final String IMAGES_ARE_DIFFERENT_SIZES = "Images are different sizes.";
+
+    public static final String KNOWN_BAD_TEST_IMAGES_DIFFERENT = "Images are different. This is a known broken test.";
+
+    public static final String KNOWN_BAD_TEST_IMAGES_SAME = "This is (or was?) a known broken test but it is now passing, please change the test type to MUST_PASS.";
+
+    public static final String NON_DETERMINISTIC_TEST = "This is a non deterministic test, please manually review the expected and actual images to make sure they are approximately the same.";
+
+    private static final Executor executor = Executors.newSingleThreadExecutor( (r) -> {
+        Thread thread = new Thread(r);
+        thread.setDaemon(true);
+        return thread;
+    });
+
+    int tick = 0;
+
+    Collection<Integer> framesToTakeScreenshotsOn;
+
+    ScreenshotNoInputAppState screenshotAppState;
+
+    private final Object waitLock = new Object();
+
+    private final int tickToTerminateApp;
+
+    public TestDriver(ScreenshotNoInputAppState screenshotAppState, Collection<Integer> framesToTakeScreenshotsOn){
+        this.screenshotAppState = screenshotAppState;
+        this.framesToTakeScreenshotsOn = framesToTakeScreenshotsOn;
+        this.tickToTerminateApp = framesToTakeScreenshotsOn.stream().mapToInt(i -> i).max().orElse(0) + 1;
+    }
+
+    @Override
+    public void update(float tpf){
+        super.update(tpf);
+
+        if(framesToTakeScreenshotsOn.contains(tick)){
+            screenshotAppState.takeScreenshot();
+        }
+        if(tick >= tickToTerminateApp){
+            getApplication().stop(true);
+            synchronized (waitLock) {
+                waitLock.notify(); // Release the wait
+            }
+        }
+
+        tick++;
+    }
+
+    @Override protected void initialize(Application app){}
+
+    @Override protected void cleanup(Application app){}
+
+    @Override protected void onEnable(){}
+
+    @Override protected void onDisable(){}
+
+
+    /**
+     * Boots up the application on a separate thread (blocks this thread) and then does the following:
+     * - Takes screenshots on the requested frames
+     * - After all the frames have been taken it stops the application
+     * - Compares the screenshot to the expected screenshot (if any). Fails the test if they are different
+     */
+    public static void bootAppForTest(TestType testType, AppSettings appSettings, String baseImageFileName, List<Integer> framesToTakeScreenshotsOn, AppState... initialStates){
+        FastMath.rand.setSeed(0); //try to make things deterministic by setting the random seed
+        Collections.sort(framesToTakeScreenshotsOn);
+
+        Path imageTempDir;
+
+        try{
+            imageTempDir = Files.createTempDirectory("jmeSnapshotTest");
+        } catch(IOException e){
+            throw new RuntimeException(e);
+        }
+
+        ScreenshotNoInputAppState screenshotAppState = new ScreenshotNoInputAppState(imageTempDir.toString() + "/");
+        String screenshotAppFileNamePrefix = "Screenshot-";
+        screenshotAppState.setFileName(screenshotAppFileNamePrefix);
+
+        List<AppState> states = new ArrayList<>(Arrays.asList(initialStates));
+        TestDriver testDriver = new TestDriver(screenshotAppState, framesToTakeScreenshotsOn);
+        states.add(screenshotAppState);
+        states.add(testDriver);
+
+        SimpleApplication app = new App(states.toArray(new AppState[0]));
+        app.setSettings(appSettings);
+        app.setShowSettings(false);
+
+        executor.execute(() -> app.start(JmeContext.Type.Display));
+
+        synchronized (testDriver.waitLock) {
+            try {
+                testDriver.waitLock.wait(10000); // Wait for the screenshot to be taken and application to stop
+                Thread.sleep(200); //give time for openGL is fully released before starting a new test (get random JVM crashes without this)
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new RuntimeException(e);
+            }
+        }
+
+        //search the imageTempDir
+        List<Path> imageFiles = new ArrayList<>();
+        try(Stream<Path> paths = Files.list(imageTempDir)){
+            paths.forEach(imageFiles::add);
+        } catch(IOException e){
+            throw new RuntimeException(e);
+        }
+
+        //this resorts with natural numeric ordering (so App10.png comes after App9.png)
+        imageFiles.sort(new Comparator<Path>(){
+            @Override
+            public int compare(Path p1, Path p2){
+                return extractNumber(p1).compareTo(extractNumber(p2));
+            }
+
+            private Integer extractNumber(Path path){
+                String name = path.getFileName().toString();
+                int numStart = screenshotAppFileNamePrefix.length();
+                int numEnd = name.lastIndexOf(".png");
+                return Integer.parseInt(name.substring(numStart, numEnd));
+            }
+        });
+
+        if(imageFiles.isEmpty()){
+            fail("No screenshot found in the temporary directory.");
+        }
+        if(imageFiles.size() != framesToTakeScreenshotsOn.size()){
+            fail("Not all screenshots were taken, expected " + framesToTakeScreenshotsOn.size() + " but got " + imageFiles.size());
+        }
+
+        String failureMessage = null;
+
+        try {
+            for(int screenshotIndex=0;screenshotIndex<framesToTakeScreenshotsOn.size();screenshotIndex++){
+                Path generatedImage = imageFiles.get(screenshotIndex);
+                int frame = framesToTakeScreenshotsOn.get(screenshotIndex);
+
+                String thisFrameBaseImageFileName = baseImageFileName + "_f" + frame;
+
+                Path expectedImage = Paths.get("src/test/resources/" + thisFrameBaseImageFileName + ".png");
+
+                if(!Files.exists(expectedImage)){
+                    try{
+                        Path savedImage = saveGeneratedImageToChangedImages(generatedImage, thisFrameBaseImageFileName);
+                        attachImage("New image:", thisFrameBaseImageFileName + ".png", savedImage);
+                        String message = "Expected image not found, is this a new test? If so collect the new image from the step artefacts";
+                        if(failureMessage==null){ //only want the first thing to go wrong as the junit test fail reason
+                            failureMessage = message;
+                        }
+                        ExtentReportExtension.getCurrentTest().fail(message);
+                        continue;
+                    } catch(IOException e){
+                        throw new RuntimeException(e);
+                    }
+                }
+
+                BufferedImage img1 = ImageIO.read(generatedImage.toFile());
+                BufferedImage img2 = ImageIO.read(expectedImage.toFile());
+
+                if (imagesAreTheSame(img1, img2)) {
+                    if(testType == TestType.KNOWN_TO_FAIL){
+                        ExtentReportExtension.getCurrentTest().warning(KNOWN_BAD_TEST_IMAGES_SAME);
+                    }
+                } else {
+                    //save the generated image to the build directory
+                    Path savedImage = saveGeneratedImageToChangedImages(generatedImage, thisFrameBaseImageFileName);
+
+                    attachImage("Expected", thisFrameBaseImageFileName + "_expected.png", expectedImage);
+                    attachImage("Actual", thisFrameBaseImageFileName + "_actual.png", savedImage);
+                    attachImage("Diff", thisFrameBaseImageFileName + "_diff.png", createComparisonImage(img1, img2));
+
+                    switch(testType){
+                        case MUST_PASS:
+                            if(failureMessage==null){ //only want the first thing to go wrong as the junit test fail reason
+                                failureMessage = IMAGES_ARE_DIFFERENT;
+                            }
+                            ExtentReportExtension.getCurrentTest().fail(IMAGES_ARE_DIFFERENT);
+                            break;
+                        case NON_DETERMINISTIC:
+                            ExtentReportExtension.getCurrentTest().warning(NON_DETERMINISTIC_TEST);
+                            break;
+                        case KNOWN_TO_FAIL:
+                            ExtentReportExtension.getCurrentTest().warning(KNOWN_BAD_TEST_IMAGES_DIFFERENT);
+                            break;
+                    }
+                }
+
+            }
+        } catch (IOException e) {
+            throw new RuntimeException("Error reading images", e);
+        } finally{
+            clearTemporaryFolder(imageTempDir);
+        }
+
+        if(failureMessage!=null){
+            fail(failureMessage);
+        }
+    }
+
+    private static void clearTemporaryFolder(Path temporaryFolder){
+        try (Stream<Path> paths = Files.walk(temporaryFolder)) {
+            paths.sorted((a, b) -> b.getNameCount() - a.getNameCount())
+                    .forEach(path -> {
+                        try {
+                            Files.delete(path);
+                        } catch (IOException e) {
+                            throw new RuntimeException(e);
+                        }
+                    });
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Saves the image with the exact file name it needs to go into the resources directory to be a new reference image
+     * if the instigator of the change wants to accept this as the new "correct" state.
+     */
+    private static Path saveGeneratedImageToChangedImages(Path generatedImage, String imageFileName) throws IOException{
+        Path savedImage = Paths.get("build/changed-images/" + imageFileName + ".png");
+        Files.createDirectories(savedImage.getParent());
+        Files.copy(generatedImage, savedImage, StandardCopyOption.REPLACE_EXISTING);
+        aggressivelyCompressImage(savedImage);
+        return savedImage;
+    }
+
+    /**
+     * This remains lossless but makes the maximum effort to compress the image. As these images
+     * may be committed to the repository it is important to keep them as small as possible and worth the extra CPU time
+     * to do so
+     */
+    private static void aggressivelyCompressImage(Path path) throws IOException {
+        // Load your image
+        BufferedImage image = ImageIO.read(path.toFile());
+
+        // Get a PNG writer
+        ImageWriter writer = ImageIO.getImageWritersByFormatName("png").next();
+        ImageWriteParam writeParam = writer.getDefaultWriteParam();
+
+        // Increase compression effort
+        writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
+        writeParam.setCompressionQuality(0.0f); // 0.0 means maximum compression
+
+        // Save the image with increased compression
+        try (ImageOutputStream outputStream = ImageIO.createImageOutputStream(path.toFile())) {
+            writer.setOutput(outputStream);
+            writer.write(null, new IIOImage(image, null, null), writeParam);
+        }
+
+        // Clean up
+        writer.dispose();
+    }
+
+    /**
+     * Attaches the image to the report. A copy of the image is made in the report directory
+     */
+    private static void attachImage(String title, String fileName, Path originalImage) throws IOException{
+        ExtentTest test = ExtentReportExtension.getCurrentTest();
+        Files.copy(originalImage.toAbsolutePath(), Paths.get("build/reports/" + fileName), StandardCopyOption.REPLACE_EXISTING);
+        test.addScreenCaptureFromPath(fileName, title);
+    }
+
+    /**
+     * Attaches the image to the report. The image is written to the report directory
+     */
+    private static void attachImage(String title, String fileName, BufferedImage originalImage) throws IOException{
+        ExtentTest test = ExtentReportExtension.getCurrentTest();
+        ImageIO.write(originalImage, "png", Paths.get("build/reports/" + fileName).toFile());
+        test.addScreenCaptureFromPath(fileName, title);
+    }
+
+    /**
+     * Tests that the images are the same. If they are not the same it will return false (which may fail the test
+     * depending on the test type). Different sizes are so fatal that they will immediately fail the test.
+     */
+    private static boolean imagesAreTheSame(BufferedImage img1, BufferedImage img2) {
+        if (img1.getWidth() != img2.getWidth() || img1.getHeight() != img2.getHeight()) {
+            ExtentReportExtension.getCurrentTest().createNode("Image 1 size : " + img1.getWidth() + "x" + img1.getHeight());
+            ExtentReportExtension.getCurrentTest().createNode("Image 2 size : " + img2.getWidth() + "x" + img2.getHeight());
+            fail(IMAGES_ARE_DIFFERENT_SIZES);
+        }
+
+        for (int y = 0; y < img1.getHeight(); y++) {
+            for (int x = 0; x < img1.getWidth(); x++) {
+                if (img1.getRGB(x, y)  != img2.getRGB(x, y)){
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Creates an image that highlights the differences between the two images. The reference image is shown
+     * dully in grey with blue, yellow, orange and red showing where pixels are different.
+     */
+    private static BufferedImage createComparisonImage(BufferedImage img1, BufferedImage img2) {
+        BufferedImage comparisonImage = new BufferedImage(img1.getWidth(), img1.getHeight(), BufferedImage.TYPE_INT_ARGB);
+
+        for (int y = 0; y < img1.getHeight(); y++) {
+            for (int x = 0; x < img1.getWidth(); x++) {
+                PixelSamenessDegree pixelSameness = categorisePixelDifference(img1.getRGB(x, y),img2.getRGB(x, y));
+
+                if(pixelSameness == PixelSamenessDegree.SAME){
+                    int washedOutPixel = getWashedOutPixel(img1, x, y, 0.9f);
+                    //Color rawColor = new Color(img1.getRGB(x, y), true);
+                    comparisonImage.setRGB(x, y, washedOutPixel);
+                }else{
+                    comparisonImage.setRGB(x, y, pixelSameness.getColorInDebugImage().asIntARGB());
+                }
+            }
+        }
+        return comparisonImage;
+    }
+
+    /**
+     * This produces the almost grey ghost of the original image, used when the differences are being highlighted
+     */
+    public static int getWashedOutPixel(BufferedImage img, int x, int y, float alpha) {
+        // Get the raw pixel value
+        int rgb = img.getRGB(x, y);
+
+        // Extract the color components
+        int a = (rgb >> 24) & 0xFF;
+        int r = (rgb >> 16) & 0xFF;
+        int g = (rgb >> 8) & 0xFF;
+        int b = rgb & 0xFF;
+
+        // Define the overlay gray color (same value for r, g, b)
+        int gray = 128;
+
+        // Blend the original color with the gray color
+        r = (int) ((1 - alpha) * r + alpha * gray);
+        g = (int) ((1 - alpha) * g + alpha * gray);
+        b = (int) ((1 - alpha) * b + alpha * gray);
+
+        // Clamp the values to the range [0, 255]
+        r = Math.min(255, r);
+        g = Math.min(255, g);
+        b = Math.min(255, b);
+
+        // Combine the components back into a single int
+
+        return (a << 24) | (r << 16) | (g << 8) | b;
+    }
+
+    private static PixelSamenessDegree categorisePixelDifference(int pixel1, int pixel2){
+        if(pixel1 == pixel2){
+            return PixelSamenessDegree.SAME;
+        }
+
+        int pixelDifference = getMaximumComponentDifference(pixel1, pixel2);
+
+        if(pixelDifference<= PixelSamenessDegree.NEGLIGIBLY_DIFFERENT.getMaximumAllowedDifference()){
+            return PixelSamenessDegree.NEGLIGIBLY_DIFFERENT;
+        }
+        if(pixelDifference<= PixelSamenessDegree.SUBTLY_DIFFERENT.getMaximumAllowedDifference()){
+            return PixelSamenessDegree.SUBTLY_DIFFERENT;
+        }
+        if(pixelDifference<= PixelSamenessDegree.MEDIUMLY_DIFFERENT.getMaximumAllowedDifference()){
+            return PixelSamenessDegree.MEDIUMLY_DIFFERENT;
+        }
+        if(pixelDifference<= PixelSamenessDegree.VERY_DIFFERENT.getMaximumAllowedDifference()){
+            return PixelSamenessDegree.VERY_DIFFERENT;
+        }
+        return PixelSamenessDegree.EXTREMELY_DIFFERENT;
+    }
+
+    private static int getMaximumComponentDifference(int pixel1, int pixel2){
+        int r1 = (pixel1 >> 16) & 0xFF;
+        int g1 = (pixel1 >> 8) & 0xFF;
+        int b1 = pixel1 & 0xFF;
+        int a1 = (pixel1 >> 24) & 0xFF;
+
+        int r2 = (pixel2 >> 16) & 0xFF;
+        int g2 = (pixel2 >> 8) & 0xFF;
+        int b2 = pixel2 & 0xFF;
+        int a2 = (pixel2 >> 24) & 0xFF;
+
+        return Math.max(Math.abs(r1 - r2), Math.max(Math.abs(g1 - g2), Math.max(Math.abs(b1 - b2), Math.abs(a1 - a2))));
+    }
+
+}

+ 56 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestResolution.java

@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.testframework;
+
+/**
+ * The size the test should be run at. Try to keep it small to reduce the file size of the screenshots saved into the
+ * repository.
+ *
+ * @author Richard Tingle (aka richtea)
+ */
+public class TestResolution{
+    int width;
+    int height;
+
+    public TestResolution(int width, int height){
+        this.width = width;
+        this.height = height;
+    }
+
+    public int getWidth(){
+        return width;
+    }
+
+    public int getHeight(){
+        return height;
+    }
+}

+ 53 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestType.java

@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.testframework;
+
+/**
+ * Controls pass/fail behaviour of a test. Default behaviour is MUST_PASS.
+ */
+public enum TestType{
+    /**
+     * Normal test, if it fails it will fail the step
+     */
+    MUST_PASS,
+    /**
+     * Test is likely to fail because it is testing something non-deterministic (e.g. uses random numbers).
+     * This will be marked as a warning in the report but the test will not fail.
+     */
+    NON_DETERMINISTIC,
+    /**
+     * Test is known to fail, this will be marked as a warning in the report but the test will not fail.
+     * It will be marked as a warning whether it passes or fails, this is because if it has started to pass again it should
+     * be returned to a normal test with type MUST_PASS.
+     */
+    KNOWN_TO_FAIL,
+}

+ 290 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/effects/TestExplosionEffect.java

@@ -0,0 +1,290 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.effects;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.effect.ParticleEmitter;
+import com.jme3.effect.ParticleMesh;
+import com.jme3.effect.shapes.EmitterSphereShape;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.math.Quaternion;
+import com.jme3.math.Vector3f;
+import com.jme3.renderer.Camera;
+import com.jme3.scene.Node;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+/**
+ * @author Richard Tingle (aka richtea)
+ */
+public class TestExplosionEffect extends ScreenshotTestBase{
+
+    /**
+     * This test's particle effects (using an explosion)
+     */
+    @Test
+    public void testExplosionEffect(){
+        screenshotTest(new BaseAppState(){
+            private ParticleEmitter flame, flash, spark, roundspark, smoketrail, debris,
+                    shockwave;
+
+            private int state = 0;
+
+            final private Node explosionEffect = new Node("explosionFX");
+
+            @Override
+            protected void initialize(Application app){
+                createFlame();
+                createFlash();
+                createSpark();
+                createRoundSpark();
+                createSmokeTrail();
+                createDebris();
+                createShockwave();
+                explosionEffect.setLocalScale(0.5f);
+                app.getRenderManager().preloadScene(explosionEffect);
+
+                Camera camera = app.getCamera();
+                camera.setLocation(new Vector3f(0, 3.5135868f, 10));
+                camera.setRotation(new Quaternion(1.5714673E-4f, 0.98696727f, -0.16091813f, 9.6381607E-4f));
+                Node rootNode = ((SimpleApplication)app).getRootNode();
+                rootNode.attachChild(explosionEffect);
+            }
+
+            @Override
+            protected void cleanup(Application app){}
+
+            @Override
+            protected void onEnable(){}
+
+            @Override
+            protected void onDisable(){}
+
+            private void createFlame(){
+                flame = new ParticleEmitter("Flame", ParticleMesh.Type.Point, 32);
+                flame.setSelectRandomImage(true);
+                flame.setStartColor(new ColorRGBA(1f, 0.4f, 0.05f, 1f ));
+                flame.setEndColor(new ColorRGBA(.4f, .22f, .12f, 0f));
+                flame.setStartSize(1.3f);
+                flame.setEndSize(2f);
+                flame.setShape(new EmitterSphereShape(Vector3f.ZERO, 1f));
+                flame.setParticlesPerSec(0);
+                flame.setGravity(0, -5, 0);
+                flame.setLowLife(.4f);
+                flame.setHighLife(.5f);
+                flame.getParticleInfluencer().setInitialVelocity(new Vector3f(0, 7, 0));
+                flame.getParticleInfluencer().setVelocityVariation(1f);
+                flame.setImagesX(2);
+                flame.setImagesY(2);
+
+                AssetManager assetManager = getApplication().getAssetManager();
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Explosion/flame.png"));
+                mat.setBoolean("PointSprite", true);
+                flame.setMaterial(mat);
+                explosionEffect.attachChild(flame);
+            }
+
+            private void createFlash(){
+                AssetManager assetManager = getApplication().getAssetManager();
+                flash = new ParticleEmitter("Flash", ParticleMesh.Type.Point, 24 );
+                flash.setSelectRandomImage(true);
+                flash.setStartColor(new ColorRGBA(1f, 0.8f, 0.36f, 1f ));
+                flash.setEndColor(new ColorRGBA(1f, 0.8f, 0.36f, 0f));
+                flash.setStartSize(.1f);
+                flash.setEndSize(3.0f);
+                flash.setShape(new EmitterSphereShape(Vector3f.ZERO, .05f));
+                flash.setParticlesPerSec(0);
+                flash.setGravity(0, 0, 0);
+                flash.setLowLife(.2f);
+                flash.setHighLife(.2f);
+                flash.getParticleInfluencer()
+                        .setInitialVelocity(new Vector3f(0, 5f, 0));
+                flash.getParticleInfluencer().setVelocityVariation(1);
+                flash.setImagesX(2);
+                flash.setImagesY(2);
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Explosion/flash.png"));
+                mat.setBoolean("PointSprite", true);
+                flash.setMaterial(mat);
+                explosionEffect.attachChild(flash);
+            }
+
+            private void createRoundSpark(){
+                AssetManager assetManager = getApplication().getAssetManager();
+                roundspark = new ParticleEmitter("RoundSpark", ParticleMesh.Type.Point, 20 );
+                roundspark.setStartColor(new ColorRGBA(1f, 0.29f, 0.34f, (float) (1.0 )));
+                roundspark.setEndColor(new ColorRGBA(0, 0, 0, 0.5f ));
+                roundspark.setStartSize(1.2f);
+                roundspark.setEndSize(1.8f);
+                roundspark.setShape(new EmitterSphereShape(Vector3f.ZERO, 2f));
+                roundspark.setParticlesPerSec(0);
+                roundspark.setGravity(0, -.5f, 0);
+                roundspark.setLowLife(1.8f);
+                roundspark.setHighLife(2f);
+                roundspark.getParticleInfluencer()
+                        .setInitialVelocity(new Vector3f(0, 3, 0));
+                roundspark.getParticleInfluencer().setVelocityVariation(.5f);
+                roundspark.setImagesX(1);
+                roundspark.setImagesY(1);
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Explosion/roundspark.png"));
+                mat.setBoolean("PointSprite", true);
+                roundspark.setMaterial(mat);
+                explosionEffect.attachChild(roundspark);
+            }
+
+            private void createSpark(){
+                AssetManager assetManager = getApplication().getAssetManager();
+                spark = new ParticleEmitter("Spark", ParticleMesh.Type.Triangle, 30 );
+                spark.setStartColor(new ColorRGBA(1f, 0.8f, 0.36f, 1.0f));
+                spark.setEndColor(new ColorRGBA(1f, 0.8f, 0.36f, 0f));
+                spark.setStartSize(.5f);
+                spark.setEndSize(.5f);
+                spark.setFacingVelocity(true);
+                spark.setParticlesPerSec(0);
+                spark.setGravity(0, 5, 0);
+                spark.setLowLife(1.1f);
+                spark.setHighLife(1.5f);
+                spark.getParticleInfluencer().setInitialVelocity(new Vector3f(0, 20, 0));
+                spark.getParticleInfluencer().setVelocityVariation(1);
+                spark.setImagesX(1);
+                spark.setImagesY(1);
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Explosion/spark.png"));
+                spark.setMaterial(mat);
+                explosionEffect.attachChild(spark);
+            }
+
+            private void createSmokeTrail(){
+                AssetManager assetManager = getApplication().getAssetManager();
+                smoketrail = new ParticleEmitter("SmokeTrail", ParticleMesh.Type.Triangle, 22 );
+                smoketrail.setStartColor(new ColorRGBA(1f, 0.8f, 0.36f, 1.0f ));
+                smoketrail.setEndColor(new ColorRGBA(1f, 0.8f, 0.36f, 0f));
+                smoketrail.setStartSize(.2f);
+                smoketrail.setEndSize(1f);
+
+                smoketrail.setFacingVelocity(true);
+                smoketrail.setParticlesPerSec(0);
+                smoketrail.setGravity(0, 1, 0);
+                smoketrail.setLowLife(.4f);
+                smoketrail.setHighLife(.5f);
+                smoketrail.getParticleInfluencer()
+                        .setInitialVelocity(new Vector3f(0, 12, 0));
+                smoketrail.getParticleInfluencer().setVelocityVariation(1);
+                smoketrail.setImagesX(1);
+                smoketrail.setImagesY(3);
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Explosion/smoketrail.png"));
+                smoketrail.setMaterial(mat);
+                explosionEffect.attachChild(smoketrail);
+            }
+
+            private void createDebris(){
+                AssetManager assetManager = getApplication().getAssetManager();
+                debris = new ParticleEmitter("Debris", ParticleMesh.Type.Triangle, 15 );
+                debris.setSelectRandomImage(true);
+                debris.setRandomAngle(true);
+                debris.setRotateSpeed(FastMath.TWO_PI * 4);
+                debris.setStartColor(new ColorRGBA(1f, 0.59f, 0.28f, 1.0f ));
+                debris.setEndColor(new ColorRGBA(.5f, 0.5f, 0.5f, 0f));
+                debris.setStartSize(.2f);
+                debris.setEndSize(.2f);
+
+//        debris.setShape(new EmitterSphereShape(Vector3f.ZERO, .05f));
+                debris.setParticlesPerSec(0);
+                debris.setGravity(0, 12f, 0);
+                debris.setLowLife(1.4f);
+                debris.setHighLife(1.5f);
+                debris.getParticleInfluencer()
+                        .setInitialVelocity(new Vector3f(0, 15, 0));
+                debris.getParticleInfluencer().setVelocityVariation(.60f);
+                debris.setImagesX(3);
+                debris.setImagesY(3);
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Explosion/Debris.png"));
+                debris.setMaterial(mat);
+                explosionEffect.attachChild(debris);
+            }
+
+            private void createShockwave(){
+                AssetManager assetManager = getApplication().getAssetManager();
+                shockwave = new ParticleEmitter("Shockwave", ParticleMesh.Type.Triangle, 1);
+//        shockwave.setRandomAngle(true);
+                shockwave.setFaceNormal(Vector3f.UNIT_Y);
+                shockwave.setStartColor(new ColorRGBA(.48f, 0.17f, 0.01f, .8f));
+                shockwave.setEndColor(new ColorRGBA(.48f, 0.17f, 0.01f, 0f));
+
+                shockwave.setStartSize(0f);
+                shockwave.setEndSize(7f);
+
+                shockwave.setParticlesPerSec(0);
+                shockwave.setGravity(0, 0, 0);
+                shockwave.setLowLife(0.5f);
+                shockwave.setHighLife(0.5f);
+                shockwave.getParticleInfluencer()
+                        .setInitialVelocity(new Vector3f(0, 0, 0));
+                shockwave.getParticleInfluencer().setVelocityVariation(0f);
+                shockwave.setImagesX(1);
+                shockwave.setImagesY(1);
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Explosion/shockwave.png"));
+                shockwave.setMaterial(mat);
+                explosionEffect.attachChild(shockwave);
+            }
+            @Override
+            public void update(float tpf){
+                if (state == 0){
+                    flash.emitAllParticles();
+                    spark.emitAllParticles();
+                    smoketrail.emitAllParticles();
+                    debris.emitAllParticles();
+                    shockwave.emitAllParticles();
+                    state++;
+                }
+                if (state == 1){
+                    flame.emitAllParticles();
+                    roundspark.emitAllParticles();
+                    state++;
+                }
+            }
+
+        }).setFramesToTakeScreenshotsOn(2,15)
+          .run();
+    }
+
+}

+ 266 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/effects/TestIssue1773.java

@@ -0,0 +1,266 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.effects;
+
+import com.jme3.animation.LoopMode;
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.cinematic.MotionPath;
+import com.jme3.cinematic.events.MotionEvent;
+import com.jme3.effect.ParticleEmitter;
+import com.jme3.effect.ParticleMesh;
+import com.jme3.effect.shapes.EmitterMeshVertexShape;
+import com.jme3.light.AmbientLight;
+import com.jme3.light.DirectionalLight;
+import com.jme3.material.Material;
+import com.jme3.material.Materials;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.math.Vector2f;
+import com.jme3.math.Vector3f;
+import com.jme3.post.FilterPostProcessor;
+import com.jme3.post.filters.BloomFilter;
+import com.jme3.post.filters.FXAAFilter;
+import com.jme3.renderer.Camera;
+import com.jme3.renderer.ViewPort;
+import com.jme3.renderer.queue.RenderQueue;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Node;
+import com.jme3.scene.shape.CenterQuad;
+import com.jme3.scene.shape.Torus;
+import com.jme3.shadow.DirectionalLightShadowFilter;
+import com.jme3.texture.Texture;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.Arrays;
+
+/**
+ * @author Richard Tingle (aka richtea)
+ */
+public class TestIssue1773 extends ScreenshotTestBase{
+
+    /**
+     * Test case for Issue 1773 (Wrong particle position when using
+     * 'EmitterMeshVertexShape' or 'EmitterMeshFaceShape' and worldSpace
+     * flag equal to true)
+     *
+     * If the test succeeds, the particles will be generated from the vertices
+     * (for EmitterMeshVertexShape) or from the faces (for EmitterMeshFaceShape)
+     * of the torus mesh. If the test fails, the particles will appear in the
+     * center of the torus when worldSpace flag is set to true.
+     *
+     */
+    @ParameterizedTest(name = "Test Issue 1773 (emit in worldSpace = {0})")
+    @ValueSource(booleans = {true, false})
+    public void testIssue1773(boolean worldSpace, TestInfo testInfo){
+
+        String imageName = testInfo.getTestClass().get().getName() + "." + testInfo.getTestMethod().get().getName() + (worldSpace ? "_worldSpace" : "_localSpace");
+
+        screenshotTest(new BaseAppState(){
+            private ParticleEmitter emit;
+            private Node myModel;
+
+            AssetManager assetManager;
+
+            Node rootNode;
+
+            Camera cam;
+
+            ViewPort viewPort;
+
+            @Override
+            public void initialize(Application app) {
+                assetManager = app.getAssetManager();
+                rootNode = ((SimpleApplication)app).getRootNode();
+                cam = app.getCamera();
+                viewPort = app.getViewPort();
+                configCamera();
+                setupLights();
+                setupGround();
+                setupCircle();
+                createMotionControl();
+            }
+
+            @Override
+            protected void cleanup(Application app){}
+
+            @Override
+            protected void onEnable(){}
+
+            @Override
+            protected void onDisable(){}
+
+            /**
+             * Crates particle emitter and adds it to root node.
+             */
+            private void setupCircle() {
+                myModel = new Node("FieryCircle");
+
+                Geometry torus = createTorus(1f);
+                myModel.attachChild(torus);
+
+                emit = createParticleEmitter(torus, true);
+                myModel.attachChild(emit);
+
+                rootNode.attachChild(myModel);
+            }
+
+            /**
+             * Creates torus geometry used for the emitter shape.
+             */
+            private Geometry createTorus(float radius) {
+                float s = radius / 8f;
+                Geometry geo = new Geometry("CircleXZ", new Torus(64, 4, s, radius));
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
+                mat.setColor("Color", ColorRGBA.Blue);
+                mat.getAdditionalRenderState().setWireframe(true);
+                geo.setMaterial(mat);
+                return geo;
+            }
+
+            /**
+             * Creates a particle emitter that will emit the particles from
+             * the given shape's vertices.
+             */
+            private ParticleEmitter createParticleEmitter(Geometry geo, boolean pointSprite) {
+                ParticleMesh.Type type = pointSprite ? ParticleMesh.Type.Point : ParticleMesh.Type.Triangle;
+                ParticleEmitter emitter = new ParticleEmitter("Emitter", type, 1000);
+                Material mat = new Material(assetManager, "Common/MatDefs/Misc/Particle.j3md");
+                mat.setTexture("Texture", assetManager.loadTexture("Effects/Smoke/Smoke.png"));
+                mat.setBoolean("PointSprite", pointSprite);
+                emitter.setMaterial(mat);
+                emitter.setLowLife(1);
+                emitter.setHighLife(1);
+                emitter.setImagesX(15);
+                emitter.setStartSize(0.04f);
+                emitter.setEndSize(0.02f);
+                emitter.setStartColor(ColorRGBA.Orange);
+                emitter.setEndColor(ColorRGBA.Red);
+                emitter.setParticlesPerSec(900);
+                emitter.setGravity(0, 0f, 0);
+                //emitter.getParticleInfluencer().setVelocityVariation(1);
+                //emitter.getParticleInfluencer().setInitialVelocity(new Vector3f(0, .5f, 0));
+                emitter.setShape(new EmitterMeshVertexShape(Arrays.asList(geo.getMesh())));
+                emitter.setInWorldSpace(worldSpace);
+                //emitter.setShape(new EmitterMeshFaceShape(Arrays.asList(geo.getMesh())));
+                return emitter;
+            }
+
+            /**
+             * Creates a motion control that will move particle emitter in
+             * a circular path.
+             */
+            private void createMotionControl() {
+
+                float radius = 5f;
+                float height = 1.10f;
+
+                MotionPath path = new MotionPath();
+                path.setCycle(true);
+
+                for (int i = 0; i < 8; i++) {
+                    float x = FastMath.sin(FastMath.QUARTER_PI * i) * radius;
+                    float z = FastMath.cos(FastMath.QUARTER_PI * i) * radius;
+                    path.addWayPoint(new Vector3f(x, height, z));
+                }
+                MotionEvent motionControl = new MotionEvent(myModel, path);
+                motionControl.setLoopMode(LoopMode.Loop);
+                motionControl.setDirectionType(MotionEvent.Direction.Path);
+                motionControl.play();
+            }
+
+            private void configCamera() {
+                cam.setLocation(new Vector3f(0, 6f, 9.2f));
+                cam.lookAt(Vector3f.UNIT_Y, Vector3f.UNIT_Y);
+
+                float aspect = (float) cam.getWidth() / cam.getHeight();
+                cam.setFrustumPerspective(45, aspect, 0.1f, 1000f);
+            }
+
+            /**
+             * Adds a ground to the scene
+             */
+            private void setupGround() {
+                CenterQuad quad = new CenterQuad(12, 12);
+                quad.scaleTextureCoordinates(new Vector2f(2, 2));
+                Geometry floor = new Geometry("Floor", quad);
+                Material mat = new Material(assetManager, Materials.LIGHTING);
+                Texture tex = assetManager.loadTexture("Interface/Logo/Monkey.jpg");
+                tex.setWrap(Texture.WrapMode.Repeat);
+                mat.setTexture("DiffuseMap", tex);
+                floor.setMaterial(mat);
+                floor.rotate(-FastMath.HALF_PI, 0, 0);
+                rootNode.attachChild(floor);
+            }
+
+            /**
+             * Adds lights and filters
+             */
+            private void setupLights() {
+                viewPort.setBackgroundColor(ColorRGBA.DarkGray);
+                rootNode.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
+
+                AmbientLight ambient = new AmbientLight();
+                ambient.setColor(ColorRGBA.White);
+                //rootNode.addLight(ambient);
+
+                DirectionalLight sun = new DirectionalLight();
+                sun.setDirection((new Vector3f(-0.5f, -0.5f, -0.5f)).normalizeLocal());
+                sun.setColor(ColorRGBA.White);
+                rootNode.addLight(sun);
+
+                DirectionalLightShadowFilter dlsf = new DirectionalLightShadowFilter(assetManager, 4096, 3);
+                dlsf.setLight(sun);
+                dlsf.setShadowIntensity(0.4f);
+                dlsf.setShadowZExtend(256);
+
+                FXAAFilter fxaa = new FXAAFilter();
+                BloomFilter bloom = new BloomFilter(BloomFilter.GlowMode.Objects);
+
+                FilterPostProcessor fpp = new FilterPostProcessor(assetManager);
+                fpp.addFilter(bloom);
+                fpp.addFilter(dlsf);
+                fpp.addFilter(fxaa);
+                viewPort.addProcessor(fpp);
+            }
+
+
+        }).setFramesToTakeScreenshotsOn(45)
+          .setBaseImageFileName(imageName)
+          .run();
+    }
+}

+ 118 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/export/TestOgreConvert.java

@@ -0,0 +1,118 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.export;
+
+import com.jme3.anim.AnimComposer;
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.export.binary.BinaryExporter;
+import com.jme3.export.binary.BinaryImporter;
+import com.jme3.light.DirectionalLight;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Vector3f;
+import com.jme3.renderer.Camera;
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+/**
+ * @author Richard Tingle (aka richtea)
+ */
+public class TestOgreConvert extends ScreenshotTestBase{
+
+    /**
+     * This tests loads an Ogre model, converts it to binary, and then reloads it.
+     * <p>
+     * Note that the model is animated and the animation is played back. That is why
+     * two screenshots are taken
+     * </p>
+     */
+    @Test
+    public void testOgreConvert(){
+
+        screenshotTest(
+                new BaseAppState(){
+                    @Override
+                    protected void initialize(Application app){
+                        AssetManager assetManager = app.getAssetManager();
+                        Node rootNode = ((SimpleApplication)app).getRootNode();
+                        Camera cam = app.getCamera();
+                        Spatial ogreModel = assetManager.loadModel("Models/Oto/Oto.mesh.xml");
+
+                        DirectionalLight dl = new DirectionalLight();
+                        dl.setColor(ColorRGBA.White);
+                        dl.setDirection(new Vector3f(0,-1,-1).normalizeLocal());
+                        rootNode.addLight(dl);
+
+                        cam.setLocation(new Vector3f(0, 0, 15));
+
+                        try {
+                            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+                            BinaryExporter exp = new BinaryExporter();
+                            exp.save(ogreModel, baos);
+
+                            ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
+                            BinaryImporter imp = new BinaryImporter();
+                            imp.setAssetManager(assetManager);
+                            Node ogreModelReloaded = (Node) imp.load(bais, null, null);
+
+                            AnimComposer composer = ogreModelReloaded.getControl(AnimComposer.class);
+                            composer.setCurrentAction("Walk");
+
+                            rootNode.attachChild(ogreModelReloaded);
+                        } catch (IOException ex){
+                            throw new RuntimeException(ex);
+                        }
+                    }
+
+                    @Override
+                    protected void cleanup(Application app){}
+
+                    @Override
+                    protected void onEnable(){}
+
+                    @Override
+                    protected void onDisable(){}
+                }
+        )
+        .setFramesToTakeScreenshotsOn(1, 5)
+        .run();
+
+    }
+}

+ 98 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/gui/TestBitmapText3D.java

@@ -0,0 +1,98 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.gui;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.font.BitmapFont;
+import com.jme3.font.BitmapText;
+import com.jme3.font.Rectangle;
+import com.jme3.renderer.queue.RenderQueue;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Node;
+import com.jme3.scene.shape.Quad;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+/**
+ * @author Richard Tingle (aka richtea)
+ */
+public class TestBitmapText3D extends ScreenshotTestBase{
+
+    /**
+     * This tests both that bitmap text is rendered correctly and that it is
+     * wrapped correctly.
+     */
+    @Test
+    public void testBitmapText3D(){
+
+        screenshotTest(
+                new BaseAppState(){
+                    @Override
+                    protected void initialize(Application app){
+                        String txtB = "ABCDEFGHIKLMNOPQRSTUVWXYZ1234567890`~!@#$%^&*()-=_+[]\\;',./{}|:<>?";
+
+                        AssetManager assetManager = app.getAssetManager();
+                        Node rootNode = ((SimpleApplication)app).getRootNode();
+
+                        Quad q = new Quad(6, 3);
+                        Geometry g = new Geometry("quad", q);
+                        g.setLocalTranslation(-1.5f, -3, -0.0001f);
+                        g.setMaterial(assetManager.loadMaterial("Common/Materials/RedColor.j3m"));
+                        rootNode.attachChild(g);
+
+                        BitmapFont fnt = assetManager.loadFont("Interface/Fonts/Default.fnt");
+                        BitmapText txt = new BitmapText(fnt);
+                        txt.setBox(new Rectangle(0, 0, 6, 3));
+                        txt.setQueueBucket(RenderQueue.Bucket.Transparent);
+                        txt.setSize( 0.5f );
+                        txt.setText(txtB);
+                        txt.setLocalTranslation(-1.5f,0,0);
+                        rootNode.attachChild(txt);
+                    }
+
+                    @Override
+                    protected void cleanup(Application app){}
+
+                    @Override
+                    protected void onEnable(){}
+
+                    @Override
+                    protected void onDisable(){}
+                }
+        ).run();
+
+
+    }
+}

+ 212 - 0
jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/water/TestPostWater.java

@@ -0,0 +1,212 @@
+/*
+ * Copyright (c) 2024 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.water;
+
+import com.jme3.app.Application;
+import com.jme3.app.SimpleApplication;
+import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
+import com.jme3.light.AmbientLight;
+import com.jme3.light.DirectionalLight;
+import com.jme3.material.Material;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.Quaternion;
+import com.jme3.math.Vector3f;
+import com.jme3.post.FilterPostProcessor;
+import com.jme3.post.filters.BloomFilter;
+import com.jme3.post.filters.DepthOfFieldFilter;
+import com.jme3.post.filters.FXAAFilter;
+import com.jme3.post.filters.LightScatteringFilter;
+import com.jme3.renderer.queue.RenderQueue.ShadowMode;
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+import com.jme3.terrain.geomipmap.TerrainQuad;
+import com.jme3.terrain.heightmap.AbstractHeightMap;
+import com.jme3.terrain.heightmap.ImageBasedHeightMap;
+import com.jme3.texture.Texture;
+import com.jme3.texture.Texture.WrapMode;
+import com.jme3.texture.Texture2D;
+import com.jme3.util.SkyFactory;
+import com.jme3.util.SkyFactory.EnvMapType;
+import com.jme3.water.WaterFilter;
+import org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase;
+import org.junit.jupiter.api.Test;
+
+/**
+ * @author Richard Tingle (aka richtea)
+ */
+public class TestPostWater extends ScreenshotTestBase{
+
+    /**
+     * This test creates a scene with a terrain and post process water filter.
+     */
+    @Test
+    public void testPostWater(){
+       screenshotTest(new BaseAppState(){
+           @Override
+            protected void initialize(Application app){
+                Vector3f lightDir = new Vector3f(-4.9236743f, -1.27054665f, 5.896916f);
+                SimpleApplication simpleApplication = ((SimpleApplication)app);
+                Node rootNode = simpleApplication.getRootNode();
+                AssetManager assetManager = simpleApplication.getAssetManager();
+
+                Node mainScene = new Node("Main Scene");
+                rootNode.attachChild(mainScene);
+
+                createTerrain(mainScene, assetManager);
+                DirectionalLight sun = new DirectionalLight();
+                sun.setDirection(lightDir);
+                sun.setColor(ColorRGBA.White.clone().multLocal(1f));
+                mainScene.addLight(sun);
+
+                AmbientLight al = new AmbientLight();
+                al.setColor(new ColorRGBA(0.1f, 0.1f, 0.1f, 1.0f));
+                mainScene.addLight(al);
+
+
+                simpleApplication.getCamera().setLocation(new Vector3f(-370.31592f, 182.04016f, 196.81192f));
+                simpleApplication.getCamera().setRotation(new Quaternion(0.10058216f, 0.51807004f, -0.061508257f, 0.8471738f));
+
+                Spatial sky = SkyFactory.createSky(assetManager,
+                        "Scenes/Beach/FullskiesSunset0068.dds", EnvMapType.CubeMap);
+                sky.setLocalScale(350);
+
+                mainScene.attachChild(sky);
+                simpleApplication.getCamera().setFrustumFar(4000);
+
+                //Water Filter
+                WaterFilter water = new WaterFilter(rootNode, lightDir);
+                water.setWaterColor(new ColorRGBA().setAsSrgb(0.0078f, 0.3176f, 0.5f, 1.0f));
+                water.setDeepWaterColor(new ColorRGBA().setAsSrgb(0.0039f, 0.00196f, 0.145f, 1.0f));
+                water.setUnderWaterFogDistance(80);
+                water.setWaterTransparency(0.12f);
+                water.setFoamIntensity(0.4f);
+                water.setFoamHardness(0.3f);
+                water.setFoamExistence(new Vector3f(0.8f, 8f, 1f));
+                water.setReflectionDisplace(50);
+                water.setRefractionConstant(0.25f);
+                water.setColorExtinction(new Vector3f(30, 50, 70));
+                water.setCausticsIntensity(0.4f);
+                water.setWaveScale(0.003f);
+                water.setMaxAmplitude(2f);
+                water.setFoamTexture((Texture2D) assetManager.loadTexture("Common/MatDefs/Water/Textures/foam2.jpg"));
+                water.setRefractionStrength(0.2f);
+                //0.8f;
+                float initialWaterHeight = 90f;
+                water.setWaterHeight(initialWaterHeight);
+
+                //Bloom Filter
+                BloomFilter bloom = new BloomFilter();
+                bloom.setExposurePower(55);
+                bloom.setBloomIntensity(1.0f);
+
+                //Light Scattering Filter
+                LightScatteringFilter lsf = new LightScatteringFilter(lightDir.mult(-300));
+                lsf.setLightDensity(0.5f);
+
+                //Depth of field Filter
+                DepthOfFieldFilter dof = new DepthOfFieldFilter();
+                dof.setFocusDistance(0);
+                dof.setFocusRange(100);
+
+                FilterPostProcessor fpp = new FilterPostProcessor(assetManager);
+
+                fpp.addFilter(water);
+                fpp.addFilter(bloom);
+                fpp.addFilter(dof);
+                fpp.addFilter(lsf);
+                fpp.addFilter(new FXAAFilter());
+
+                int numSamples = simpleApplication.getContext().getSettings().getSamples();
+                if (numSamples > 0) {
+                    fpp.setNumSamples(numSamples);
+                }
+                simpleApplication.getViewPort().addProcessor(fpp);
+            }
+
+            @Override protected void cleanup(Application app){}
+
+            @Override protected void onEnable(){}
+
+            @Override protected void onDisable(){}
+
+            @Override
+            public void update(float tpf){
+                super.update(tpf);
+            }
+
+            private void createTerrain(Node rootNode, AssetManager assetManager) {
+                Material matRock = new Material(assetManager,
+                        "Common/MatDefs/Terrain/TerrainLighting.j3md");
+                matRock.setBoolean("useTriPlanarMapping", false);
+                matRock.setBoolean("WardIso", true);
+                matRock.setTexture("AlphaMap", assetManager.loadTexture("Textures/Terrain/splat/alphamap.png"));
+                Texture heightMapImage = assetManager.loadTexture("Textures/Terrain/splat/mountains512.png");
+                Texture grass = assetManager.loadTexture("Textures/Terrain/splat/grass.jpg");
+                grass.setWrap(WrapMode.Repeat);
+                matRock.setTexture("DiffuseMap", grass);
+                matRock.setFloat("DiffuseMap_0_scale", 64);
+                Texture dirt = assetManager.loadTexture("Textures/Terrain/splat/dirt.jpg");
+                dirt.setWrap(WrapMode.Repeat);
+                matRock.setTexture("DiffuseMap_1", dirt);
+                matRock.setFloat("DiffuseMap_1_scale", 16);
+                Texture rock = assetManager.loadTexture("Textures/Terrain/splat/road.jpg");
+                rock.setWrap(WrapMode.Repeat);
+                matRock.setTexture("DiffuseMap_2", rock);
+                matRock.setFloat("DiffuseMap_2_scale", 128);
+                Texture normalMap0 = assetManager.loadTexture("Textures/Terrain/splat/grass_normal.jpg");
+                normalMap0.setWrap(WrapMode.Repeat);
+                Texture normalMap1 = assetManager.loadTexture("Textures/Terrain/splat/dirt_normal.png");
+                normalMap1.setWrap(WrapMode.Repeat);
+                Texture normalMap2 = assetManager.loadTexture("Textures/Terrain/splat/road_normal.png");
+                normalMap2.setWrap(WrapMode.Repeat);
+                matRock.setTexture("NormalMap", normalMap0);
+                matRock.setTexture("NormalMap_1", normalMap1);
+                matRock.setTexture("NormalMap_2", normalMap2);
+
+                AbstractHeightMap heightmap = new ImageBasedHeightMap(heightMapImage.getImage(), 0.25f);
+                heightmap.load();
+
+                TerrainQuad terrain
+                        = new TerrainQuad("terrain", 65, 513, heightmap.getHeightMap());
+                terrain.setMaterial(matRock);
+                terrain.setLocalScale(new Vector3f(5, 5, 5));
+                terrain.setLocalTranslation(new Vector3f(0, -30, 0));
+                terrain.setLocked(false); // unlock it so we can edit the height
+
+                terrain.setShadowMode(ShadowMode.Receive);
+                rootNode.attachChild(terrain);
+            }
+        }).run();
+    }
+
+}

binární
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestExplosionEffect.testExplosionEffect_f15.png


binární
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestExplosionEffect.testExplosionEffect_f2.png


binární
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestIssue1773.testIssue1773_localSpace_f45.png


binární
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestIssue1773.testIssue1773_worldSpace_f45.png


binární
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.export.TestOgreConvert.testOgreConvert_f1.png


binární
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.export.TestOgreConvert.testOgreConvert_f5.png


binární
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.gui.TestBitmapText3D.testBitmapText3D_f1.png


binární
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.water.TestPostWater.testPostWater_f1.png


+ 2 - 2
jme3-terrain/src/main/java/com/jme3/terrain/geomipmap/TerrainQuad.java

@@ -56,7 +56,7 @@ import com.jme3.terrain.geomipmap.lodcalc.LodCalculator;
 import com.jme3.terrain.geomipmap.picking.BresenhamTerrainPicker;
 import com.jme3.terrain.geomipmap.picking.TerrainPickData;
 import com.jme3.terrain.geomipmap.picking.TerrainPicker;
-import com.jme3.util.TangentBinormalGenerator;
+import com.jme3.util.TangentUtils;
 import com.jme3.util.clone.Cloner;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -677,7 +677,7 @@ public class TerrainQuad extends Node implements Terrain {
                 ((TerrainQuad)child).generateDebugTangents(mat);
             } else if (child instanceof TerrainPatch) {
                 Geometry debug = new Geometry( "Debug " + name,
-                    TangentBinormalGenerator.genTbnLines( ((TerrainPatch)child).getMesh(), 0.8f));
+                        TangentUtils.genTbnLines(((TerrainPatch) child).getMesh(), 0.8f));
                 attachChild(debug);
                 debug.setLocalTranslation(child.getLocalTranslation());
                 debug.setCullHint(CullHint.Never);

+ 1 - 1
jme3-testdata/src/main/resources/Models/Tank/tank.j3m

@@ -1,4 +1,4 @@
-Material Tank : Common/MatDefs/Light/PBRLighting.j3md {
+Material Tank : Common/MatDefs/Light/modular/PBRLighting.j3md {
      MaterialParameters {
         
         MetallicRoughnessMap : Flip Models/Tank/Tank_Occ_Rough_Metal.png         

+ 68 - 54
jme3-vr/src/main/java/com/jme3/system/lwjgl/LwjglDisplayVR.java

@@ -1,54 +1,68 @@
-/*
- * Copyright (c) 2009-2012 jMonkeyEngine
- * All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are
- * met:
- *
- * * Redistributions of source code must retain the above copyright
- *   notice, this list of conditions and the following disclaimer.
- *
- * * Redistributions in binary form must reproduce the above copyright
- *   notice, this list of conditions and the following disclaimer in the
- *   documentation and/or other materials provided with the distribution.
- *
- * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
- *   may be used to endorse or promote products derived from this software
- *   without specific prior written permission.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
- * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
- * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
- * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
- * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
- * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
- * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
- * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
- * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
- * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
- * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- */
-package com.jme3.system.lwjgl;
-
-import com.jme3.opencl.Context;
-
-/**
- * A VR oriented LWJGL display.
- * @author Daniel Johansson
- * @author reden - phr00t - https://github.com/phr00t
- * @author Julien Seinturier - (c) 2016 - JOrigin project - <a href="http://www.jorigin.org">http:/www.jorigin.org</a>
- */
-public class LwjglDisplayVR extends LwjglWindowVR {
-    /**
-     * Create a new VR oriented LWJGL display.
-     */
-    public LwjglDisplayVR() {
-        super(Type.Display);
-    }
-
-    @Override
-    public Context getOpenCLContext() {
-        return null;
-    }
-}
+/*
+ * Copyright (c) 2009-2012 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.jme3.system.lwjgl;
+
+import com.jme3.opencl.Context;
+import com.jme3.system.Displays;
+
+/**
+ * A VR oriented LWJGL display.
+ * @author Daniel Johansson
+ * @author reden - phr00t - https://github.com/phr00t
+ * @author Julien Seinturier - (c) 2016 - JOrigin project - <a href="http://www.jorigin.org">http:/www.jorigin.org</a>
+ */
+public class LwjglDisplayVR extends LwjglWindowVR {
+
+    /**
+     * Create a new VR oriented LWJGL display.
+     */
+    public LwjglDisplayVR() {
+        super(Type.Display);
+    }
+
+    @Override
+    public Context getOpenCLContext() {
+        return null;
+    }
+
+    @Override
+    public Displays getDisplays() {
+        // TODO Auto-generated method stub
+        return null;
+    }
+
+    @Override
+    public int getPrimaryDisplay() {
+        // TODO Auto-generated method stub
+        return 0;
+    }
+}

+ 2 - 0
settings.gradle

@@ -41,3 +41,5 @@ include 'jme3-awt-dialogs'
 if(buildAndroidExamples == "true"){
     include 'jme3-android-examples'
 }
+include 'jme3-screenshot-tests'
+