From 2236c288076a10a955e87ec1f3491f2af5f0d530 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Wed, 15 Jul 2026 21:37:20 +0200 Subject: [PATCH 1/5] Make mobile backend settings configurable --- gradle/libs.versions.toml | 3 +- .../com/jme3/system/android/OGLESContext.java | 13 ++- .../main/java/com/jme3/app/Application.java | 12 ++ .../java/com/jme3/app/LegacyApplication.java | 5 + .../com/jme3/app/IosApplicationLauncher.java | 50 +++++++- .../com/jme3/system/ios/IGLESContext.java | 49 ++++++-- .../jme3/app/IosApplicationLauncherTest.java | 110 ++++++++++++++++++ 7 files changed, 228 insertions(+), 14 deletions(-) create mode 100644 jme3-ios/src/test/java/com/jme3/app/IosApplicationLauncherTest.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7187fe3255..9657b3520c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ checkstyle = "13.3.0" jacoco = "0.8.12" lwjgl3 = "3.4.1" angle = "2026-05-09" -libjglios = "0.7" +libjglios = "0.9" saferalloc = "0.0.10" nifty = "1.4.3" spotbugs = "4.9.8" @@ -98,4 +98,3 @@ saferalloc = ["saferalloc", "saferalloc-natives-linux-x8664", "saferalloc-native [plugins] jacoco = { id = "jacoco", version.ref = "jacoco" } - diff --git a/jme3-android/src/main/java/com/jme3/system/android/OGLESContext.java b/jme3-android/src/main/java/com/jme3/system/android/OGLESContext.java index 4bb91e0dc3..2dbb590926 100644 --- a/jme3-android/src/main/java/com/jme3/system/android/OGLESContext.java +++ b/jme3-android/src/main/java/com/jme3/system/android/OGLESContext.java @@ -315,10 +315,17 @@ public void uncaughtException(Thread thread, Throwable thrown) { GLExt.class ); } + if (settings.getBoolean("GraphicsTiming")) { + GLTimingState timingState = new GLTimingState(); + gl = (GL) GLTiming.createGLTiming( + gl, timingState, 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, GL2.class, GLES_30.class, GLFbo.class, GLExt.class); } renderer = new GLRenderer(gl, (GLExt) gl, (GLFbo) gl); + renderer.setDebugEnabled(settings.isGraphicsDebug()); renderer.initialize(); boolean blitSrgbConversion = useBlitSrgbConversion(); @@ -607,9 +614,9 @@ private boolean useBlitSrgbConversion() { } private boolean useBlitFrameBuffer() { - float mode = settings.getDisplayScaleMode(); return application != null && (useBlitSrgbConversion() - || DisplayScaleUtils.isDisabledMode(mode) || DisplayScaleUtils.isEmulatedScaleMode(mode)); + || getRenderFramebufferWidth() != framebufferWidth + || getRenderFramebufferHeight() != framebufferHeight); } private int getRenderFramebufferWidth() { diff --git a/jme3-core/src/main/java/com/jme3/app/Application.java b/jme3-core/src/main/java/com/jme3/app/Application.java index dba60e140c..092ddcf85a 100644 --- a/jme3-core/src/main/java/com/jme3/app/Application.java +++ b/jme3-core/src/main/java/com/jme3/app/Application.java @@ -113,6 +113,18 @@ public interface Application { */ public void setSettings(AppSettings settings); + /** + * Returns the display settings currently owned by this application. + * Before startup, custom implementations may return {@code null} when no + * settings have been assigned yet. + * + * @return the current settings, or {@code null} + */ + public default AppSettings getSettings() { + JmeContext currentContext = getContext(); + return currentContext == null ? null : currentContext.getSettings(); + } + /** * Sets the Timer implementation that will be used for calculating * frame times. By default, Application will use the Timer as returned diff --git a/jme3-core/src/main/java/com/jme3/app/LegacyApplication.java b/jme3-core/src/main/java/com/jme3/app/LegacyApplication.java index 21e865638f..623f9a238b 100644 --- a/jme3-core/src/main/java/com/jme3/app/LegacyApplication.java +++ b/jme3-core/src/main/java/com/jme3/app/LegacyApplication.java @@ -265,6 +265,11 @@ public void setSettings(AppSettings settings) { } } + @Override + public AppSettings getSettings() { + return settings; + } + /** * Sets the Timer implementation that will be used for calculating * frame times. By default, Application will use the Timer as returned diff --git a/jme3-ios/src/main/java/com/jme3/app/IosApplicationLauncher.java b/jme3-ios/src/main/java/com/jme3/app/IosApplicationLauncher.java index 2f9516377b..92481de442 100644 --- a/jme3-ios/src/main/java/com/jme3/app/IosApplicationLauncher.java +++ b/jme3-ios/src/main/java/com/jme3/app/IosApplicationLauncher.java @@ -32,7 +32,10 @@ package com.jme3.app; import com.jme3.system.JmeContext; +import com.jme3.system.AppSettings; +import com.jme3.system.DisplayScaleUtils; import com.jme3.system.ios.IGLESContext; +import org.ngengine.libjglios.core.LibJGLIOSEglBridge; /** * Base iOS launcher for jME @@ -43,14 +46,59 @@ public abstract class IosApplicationLauncher { protected Application app; + /** + * Creates the application early enough to configure the native iOS window + * and default framebuffer before libJGLIOS initializes graphics. + * + * @throws Exception if the application or native configuration fails + */ + public void configure() throws Exception { + Application application = getOrCreateApplication(); + AppSettings settings = application.getSettings(); + if (settings == null) { + settings = new AppSettings(true); + application.setSettings(settings); + } + + configureWindow(DisplayScaleUtils.requestsHighDensityFramebuffer(settings.getDisplayScaleMode())); + + int bitsPerPixel = settings.getBitsPerPixel(); + boolean rgba888 = settings.isGammaCorrection() || bitsPerPixel >= 24; + configureDefaultFramebuffer( + rgba888 ? 8 : 5, + rgba888 ? 8 : 6, + rgba888 ? 8 : 5, + Math.max(settings.getAlphaBits(), 0), + Math.max(settings.getDepthBits(), 0), + Math.max(settings.getStencilBits(), 0), + Math.max(settings.getSamples(), 0)); + } + public void start() { try { - startApplication(createApplication()); + startApplication(getOrCreateApplication()); } catch (Exception exception) { throw new IllegalStateException("jME application initialization failed", exception); } } + private Application getOrCreateApplication() throws Exception { + if (app == null) { + app = createApplication(); + } + return app; + } + + protected void configureWindow(boolean highPixelDensity) { + LibJGLIOSEglBridge.configureWindow(highPixelDensity); + } + + protected void configureDefaultFramebuffer(int redBits, int greenBits, int blueBits, + int alphaBits, int depthBits, int stencilBits, int samples) { + LibJGLIOSEglBridge.configureDefaultFramebuffer( + redBits, greenBits, blueBits, alphaBits, depthBits, stencilBits, samples); + } + protected void startApplication(Application application) throws Exception { app = application; if (app instanceof SimpleApplication) { diff --git a/jme3-ios/src/main/java/com/jme3/system/ios/IGLESContext.java b/jme3-ios/src/main/java/com/jme3/system/ios/IGLESContext.java index 5e7448f75e..5545dca170 100644 --- a/jme3-ios/src/main/java/com/jme3/system/ios/IGLESContext.java +++ b/jme3-ios/src/main/java/com/jme3/system/ios/IGLESContext.java @@ -89,7 +89,9 @@ public class IGLESContext implements JmeContext { protected SystemListener listener; protected IosInputHandler input; protected IosJoyInput joyInput; - protected int minFrameDuration = 0; // No FPS cap + protected long minFrameDurationNanos; + protected long lastFrameTimeNanos; + private boolean allowSwapBuffers = true; private Application application; private Material blitMaterial; private Picture blitGeometry; @@ -135,6 +137,11 @@ public Type getType() { public void setSettings(AppSettings settings) { logger.log(Level.FINE, "IGLESContext setSettings"); this.settings.copyFrom(settings); + allowSwapBuffers = settings.isSwapBuffers(); + minFrameDurationNanos = settings.getFrameRate() > 0 + ? 1_000_000_000L / settings.getFrameRate() + : 0; + lastFrameTimeNanos = 0; if (input != null) { input.loadSettings(settings); } @@ -229,13 +236,24 @@ public void create(boolean waitFor) { if (listener instanceof Application) { application = (Application) listener; } - IosGL gl = new IosGL(); + GL gl = new IosGL(); - if (settings.getBoolean("GraphicsDebug")) { - gl = (IosGL) GLDebug.createProxy(gl, gl, GL.class, GLExt.class, GLFbo.class); + if (settings.isGraphicsDebug()) { + gl = (GL) GLDebug.createProxy( + gl, gl, GL.class, GL2.class, GLES_30.class, GLExt.class, GLFbo.class); + } + if (settings.isGraphicsTiming()) { + GLTimingState timingState = new GLTimingState(); + gl = (GL) GLTiming.createGLTiming( + gl, timingState, GL.class, GL2.class, GLES_30.class, GLExt.class, GLFbo.class); + } + if (settings.isGraphicsTrace()) { + gl = (GL) GLTracer.createGlesTracer( + gl, GL.class, GL2.class, GLES_30.class, GLExt.class, GLFbo.class); } - renderer = new GLRenderer(gl, gl, gl); + renderer = new GLRenderer(gl, (GLExt) gl, (GLFbo) gl); + renderer.setDebugEnabled(settings.isGraphicsDebug()); renderer.initialize(); // ANGLE/Metal currently reports GLES3 but cannot reliably use hardware // shadow comparison through jME's shadow pipeline. @@ -276,6 +294,9 @@ public void runFrame() { if (!created.get() || listener == null) { return; } + if (!isFrameDue()) { + return; + } if (!LibJGLIOSEglBridge.makeCurrent()) { throw new IllegalStateException("Unable to make iOS EGL context current: " + LibJGLIOSEglBridge.lastError()); } @@ -284,7 +305,7 @@ public void runFrame() { if (!renderFrameWithBlitSrgbConversion()) { listener.update(); } - if (autoFlush) { + if (autoFlush && allowSwapBuffers) { LibJGLIOSEglBridge.swapBuffers(); } if (renderer != null) { @@ -292,6 +313,18 @@ public void runFrame() { } } + private boolean isFrameDue() { + if (minFrameDurationNanos <= 0) { + return true; + } + long now = System.nanoTime(); + if (lastFrameTimeNanos != 0 && now - lastFrameTimeNanos < minFrameDurationNanos) { + return false; + } + lastFrameTimeNanos = now; + return true; + } + public void resizeFramebuffer(int width, int height) { int oldWidth = framebufferWidth; int oldHeight = framebufferHeight; @@ -427,9 +460,9 @@ private boolean useBlitSrgbConversion() { } private boolean useBlitFrameBuffer() { - float mode = settings.getDisplayScaleMode(); return application != null && (useBlitSrgbConversion() - || DisplayScaleUtils.isDisabledMode(mode) || DisplayScaleUtils.isEmulatedScaleMode(mode)); + || getRenderFramebufferWidth() != framebufferWidth + || getRenderFramebufferHeight() != framebufferHeight); } private int getRenderFramebufferWidth() { diff --git a/jme3-ios/src/test/java/com/jme3/app/IosApplicationLauncherTest.java b/jme3-ios/src/test/java/com/jme3/app/IosApplicationLauncherTest.java new file mode 100644 index 0000000000..bcbc364755 --- /dev/null +++ b/jme3-ios/src/test/java/com/jme3/app/IosApplicationLauncherTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2009-2026 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.app; + +import com.jme3.system.AppSettings; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class IosApplicationLauncherTest { + + @Test + void configuresLowDensityRgb565Framebuffer() throws Exception { + AppSettings settings = new AppSettings(true); + settings.setDisplayScaleMode(AppSettings.DISPLAY_SCALE_DISABLED); + settings.setGammaCorrection(false); + settings.setBitsPerPixel(16); + settings.setAlphaBits(4); + settings.setDepthBits(16); + settings.setStencilBits(8); + settings.setSamples(4); + + TestLauncher launcher = new TestLauncher(settings); + launcher.configure(); + + assertFalse(launcher.highPixelDensity); + assertArrayEquals(new int[]{5, 6, 5, 4, 16, 8, 4}, launcher.framebufferConfig); + assertEquals(1, launcher.createCount); + assertSame(settings, launcher.app.getSettings()); + } + + @Test + void gammaCorrectionRequiresRgb888AndDpiAwareDrawable() throws Exception { + AppSettings settings = new AppSettings(true); + settings.setDisplayScaleMode(AppSettings.DISPLAY_SCALE_DPI_AWARE); + settings.setGammaCorrection(true); + settings.setBitsPerPixel(16); + + TestLauncher launcher = new TestLauncher(settings); + launcher.configure(); + + assertTrue(launcher.highPixelDensity); + assertArrayEquals(new int[]{8, 8, 8, 0, 24, 0, 0}, launcher.framebufferConfig); + } + + private static final class TestLauncher extends IosApplicationLauncher { + private final AppSettings applicationSettings; + private int createCount; + private boolean highPixelDensity; + private int[] framebufferConfig; + + private TestLauncher(AppSettings applicationSettings) { + this.applicationSettings = applicationSettings; + } + + @Override + protected Application createApplication() { + createCount++; + LegacyApplication application = new LegacyApplication(); + application.setSettings(applicationSettings); + return application; + } + + @Override + protected void configureWindow(boolean highPixelDensity) { + this.highPixelDensity = highPixelDensity; + } + + @Override + protected void configureDefaultFramebuffer(int redBits, int greenBits, int blueBits, + int alphaBits, int depthBits, int stencilBits, int samples) { + framebufferConfig = new int[]{ + redBits, greenBits, blueBits, alphaBits, depthBits, stencilBits, samples + }; + } + } +} From 5e7eadf8f41e349c3bbc8bd086e6530f2ddb46f9 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Wed, 15 Jul 2026 21:52:08 +0200 Subject: [PATCH 2/5] Fall back to software shadow comparison --- .../jme3/shadow/AbstractShadowRenderer.java | 11 ++ .../com/jme3/shadow/PssmShadowRenderer.java | 11 ++ .../shadow/ShadowCompareModeFallbackTest.java | 117 ++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 jme3-core/src/test/java/com/jme3/shadow/ShadowCompareModeFallbackTest.java diff --git a/jme3-core/src/main/java/com/jme3/shadow/AbstractShadowRenderer.java b/jme3-core/src/main/java/com/jme3/shadow/AbstractShadowRenderer.java index b618302fe9..d2e0485250 100644 --- a/jme3-core/src/main/java/com/jme3/shadow/AbstractShadowRenderer.java +++ b/jme3-core/src/main/java/com/jme3/shadow/AbstractShadowRenderer.java @@ -48,6 +48,7 @@ import com.jme3.post.SceneProcessor; import com.jme3.profile.AppProfiler; import com.jme3.renderer.Camera; +import com.jme3.renderer.Caps; import com.jme3.renderer.RenderManager; import com.jme3.renderer.Renderer; import com.jme3.renderer.ViewPort; @@ -289,6 +290,7 @@ final public void setShadowCompareMode(CompareMode compareMode) { throw new IllegalArgumentException("Shadow compare mode cannot be null"); } + compareMode = resolveShadowCompareMode(compareMode); this.shadowCompareMode = compareMode; for (Texture2D shadowMap : shadowMaps) { if (compareMode == CompareMode.Hardware) { @@ -309,6 +311,14 @@ final public void setShadowCompareMode(CompareMode compareMode) { postshadowMat.setBoolean("HardwareShadows", compareMode == CompareMode.Hardware); } + private CompareMode resolveShadowCompareMode(CompareMode requestedMode) { + if (requestedMode == CompareMode.Hardware && renderManager != null + && !renderManager.getRenderer().getCaps().contains(Caps.TextureShadowCompare)) { + return CompareMode.Software; + } + return requestedMode; + } + /** * Returns the currently shadow comparison mode. * @@ -368,6 +378,7 @@ protected Geometry createFrustum(Vector3f[] pts, int i) { public void initialize(RenderManager rm, ViewPort vp) { renderManager = rm; viewPort = vp; + setShadowCompareMode(shadowCompareMode); postTechniqueName = "PostShadow"; if (zFarOverride > 0 && frustumCam == null) { initFrustumCam(); diff --git a/jme3-core/src/main/java/com/jme3/shadow/PssmShadowRenderer.java b/jme3-core/src/main/java/com/jme3/shadow/PssmShadowRenderer.java index ef5bb782f6..387b04e7dc 100644 --- a/jme3-core/src/main/java/com/jme3/shadow/PssmShadowRenderer.java +++ b/jme3-core/src/main/java/com/jme3/shadow/PssmShadowRenderer.java @@ -42,6 +42,7 @@ import com.jme3.post.SceneProcessor; import com.jme3.profile.AppProfiler; import com.jme3.renderer.Camera; +import com.jme3.renderer.Caps; import com.jme3.renderer.RenderManager; import com.jme3.renderer.Renderer; import com.jme3.renderer.ViewPort; @@ -296,6 +297,7 @@ final public void setCompareMode(CompareMode compareMode) { throw new IllegalArgumentException("compareMode cannot be null"); } + compareMode = resolveCompareMode(compareMode); if (this.compareMode == compareMode) { return; } @@ -321,6 +323,14 @@ final public void setCompareMode(CompareMode compareMode) { applyHWShadows = true; } + private CompareMode resolveCompareMode(CompareMode requestedMode) { + if (requestedMode == CompareMode.Hardware && renderManager != null + && !renderManager.getRenderer().getCaps().contains(Caps.TextureShadowCompare)) { + return CompareMode.Software; + } + return requestedMode; + } + //debug function that create a displayable frustum private Geometry createFrustum(Vector3f[] pts, int i) { WireFrustum frustum = new WireFrustum(pts); @@ -356,6 +366,7 @@ private Geometry createFrustum(Vector3f[] pts, int i) { public void initialize(RenderManager rm, ViewPort vp) { renderManager = rm; viewPort = vp; + setCompareMode(compareMode); postTechniqueName = "PostShadow"; } diff --git a/jme3-core/src/test/java/com/jme3/shadow/ShadowCompareModeFallbackTest.java b/jme3-core/src/test/java/com/jme3/shadow/ShadowCompareModeFallbackTest.java new file mode 100644 index 0000000000..3389aceb36 --- /dev/null +++ b/jme3-core/src/test/java/com/jme3/shadow/ShadowCompareModeFallbackTest.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2009-2026 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.shadow; + +import com.jme3.asset.AssetManager; +import com.jme3.asset.DesktopAssetManager; +import com.jme3.renderer.Camera; +import com.jme3.renderer.Caps; +import com.jme3.renderer.RenderManager; +import com.jme3.renderer.ViewPort; +import com.jme3.system.NullRenderer; +import com.jme3.texture.Texture.ShadowCompareMode; +import java.util.EnumSet; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ShadowCompareModeFallbackTest { + + @Test + void fallsBackToSoftwareWhenHardwareComparisonIsUnavailable() { + DirectionalLightShadowRenderer shadows = newShadowRenderer(); + + shadows.initialize(renderManager(EnumSet.noneOf(Caps.class)), viewPort()); + + assertSoftwareMode(shadows); + } + + @Test + void retainsHardwareModeWhenComparisonIsSupported() { + DirectionalLightShadowRenderer shadows = newShadowRenderer(); + + shadows.initialize(renderManager(EnumSet.of(Caps.TextureShadowCompare)), viewPort()); + + assertEquals(CompareMode.Hardware, shadows.getShadowCompareMode()); + assertEquals(ShadowCompareMode.LessOrEqual, shadows.shadowMaps[0].getShadowCompareMode()); + assertTrue((Boolean) shadows.postshadowMat.getParam("HardwareShadows").getValue()); + } + + @Test + void rejectsUnsupportedHardwareModeAfterInitialization() { + DirectionalLightShadowRenderer shadows = newShadowRenderer(); + shadows.initialize(renderManager(EnumSet.noneOf(Caps.class)), viewPort()); + + shadows.setShadowCompareMode(CompareMode.Hardware); + + assertSoftwareMode(shadows); + } + + @SuppressWarnings("deprecation") + @Test + void legacyPssmRendererAlsoFallsBackToSoftware() { + AssetManager assetManager = new DesktopAssetManager(true); + PssmShadowRenderer shadows = new PssmShadowRenderer(assetManager, 128, 1); + + shadows.initialize(renderManager(EnumSet.noneOf(Caps.class)), viewPort()); + + assertEquals(PssmShadowRenderer.CompareMode.Software, shadows.compareMode); + assertEquals(ShadowCompareMode.Off, shadows.shadowMaps[0].getShadowCompareMode()); + assertFalse((Boolean) shadows.postshadowMat.getParam("HardwareShadows").getValue()); + } + + private static DirectionalLightShadowRenderer newShadowRenderer() { + AssetManager assetManager = new DesktopAssetManager(true); + return new DirectionalLightShadowRenderer(assetManager, 128, 1); + } + + private static RenderManager renderManager(EnumSet caps) { + return new RenderManager(new NullRenderer() { + @Override + public EnumSet getCaps() { + return caps; + } + }); + } + + private static ViewPort viewPort() { + return new ViewPort("test", new Camera(1, 1)); + } + + private static void assertSoftwareMode(DirectionalLightShadowRenderer shadows) { + assertEquals(CompareMode.Software, shadows.getShadowCompareMode()); + assertEquals(ShadowCompareMode.Off, shadows.shadowMaps[0].getShadowCompareMode()); + assertFalse((Boolean) shadows.postshadowMat.getParam("HardwareShadows").getValue()); + } +} From a07bd7f1c6eb8f02ee38cadbefeb97e54369b8fa Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 21 Jul 2026 15:05:31 +0200 Subject: [PATCH 3/5] fix highdpi and scaled postprocessing by using dedicated cameras instead of tweaking the scene camera --- .../com/jme3/post/FilterPostProcessor.java | 125 ++++++++----- .../java/com/jme3/renderer/RenderManager.java | 54 ++++-- .../main/java/com/jme3/renderer/ViewPort.java | 24 ++- .../jme3/post/FilterPostProcessorTest.java | 167 +++++++++++++++++- .../RenderManagerViewPortSizeTest.java | 95 ++++++++++ .../post/TestMultiViewDisplayScale.java | 48 +++++ .../post/ScenarioMultiViewDisplayScale.java | 144 +++++++++++++++ .../testframework/ScreenshotTest.java | 18 ++ .../testframework/TestDriver.java | 6 + 9 files changed, 616 insertions(+), 65 deletions(-) create mode 100644 jme3-core/src/test/java/com/jme3/renderer/RenderManagerViewPortSizeTest.java create mode 100644 jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/java/org/jmonkeyengine/screenshottests/post/TestMultiViewDisplayScale.java create mode 100644 jme3-screenshot-tests/jme3-screenshot-tests-shared/src/main/java/org/jmonkeyengine/screenshottests/scenarios/post/ScenarioMultiViewDisplayScale.java diff --git a/jme3-core/src/main/java/com/jme3/post/FilterPostProcessor.java b/jme3-core/src/main/java/com/jme3/post/FilterPostProcessor.java index a9399b3593..bafd61f103 100644 --- a/jme3-core/src/main/java/com/jme3/post/FilterPostProcessor.java +++ b/jme3-core/src/main/java/com/jme3/post/FilterPostProcessor.java @@ -96,12 +96,20 @@ public class FilterPostProcessor implements SceneProcessor, Savable { private FrameBuffer outputBuffer; private int width; private int height; + private Camera sceneCamera; + private final Camera multiViewRenderCamera = new Camera(1, 1); + private final Camera processingCamera = new Camera(1, 1); private float bottom; private float left; private float right; private float top; private int originalWidth; private int originalHeight; + private int cameraWidth; + private int cameraHeight; + private int logicalViewWidth; + private int logicalViewHeight; + private boolean multiViewRenderCameraInstalled; private boolean resizeWithDefaultFramebuffer; private int lastFilterIndex = -1; private boolean cameraInit = false; @@ -264,9 +272,8 @@ private void initFilter(Filter filter, ViewPort vp) { /** * Renders a filter's material onto a full-screen quad. This method * handles setting up the rendering context (framebuffer, camera, material) - * for a filter pass. It correctly resizes the camera and adjusts material - * states based on whether the target buffer is the final output buffer or an - * intermediate filter buffer. + * for a filter pass. It copies the scene camera into a dedicated processing + * camera, then adjusts only that copy for the target framebuffer. * * @param r The `Renderer` instance. * @param buff The `FrameBuffer` to render to. @@ -275,10 +282,8 @@ private void initFilter(Filter filter, ViewPort vp) { private void renderProcessing(Renderer r, FrameBuffer buff, Material mat) { // Adjust camera and viewport based on target framebuffer if (buff == outputBuffer) { - viewPort.getCamera().resize(originalWidth, originalHeight, false); - viewPort.getCamera().setViewPort(left, right, bottom, top); - // viewPort.getCamera().update(); // Redundant as resize and setViewPort call onXXXChange - renderManager.setCamera(viewPort.getCamera(), false); + Camera camera = prepareProcessingCamera(cameraWidth, cameraHeight, left, right, bottom, top); + renderManager.setCamera(camera, false, originalWidth, originalHeight); // Disable depth test/write for final pass to prevent artifacts if (mat.getAdditionalRenderState().isDepthWrite()) { mat.getAdditionalRenderState().setDepthTest(false); @@ -286,10 +291,8 @@ private void renderProcessing(Renderer r, FrameBuffer buff, Material mat) { } } else { // Rendering to an intermediate framebuffer for a filter pass - viewPort.getCamera().resize(buff.getWidth(), buff.getHeight(), false); - viewPort.getCamera().setViewPort(0, 1, 0, 1); - // viewPort.getCamera().update(); // Redundant as resize and setViewPort call onXXXChange - renderManager.setCamera(viewPort.getCamera(), false); + Camera camera = prepareProcessingCamera(logicalViewWidth, logicalViewHeight, 0f, 1f, 0f, 1f); + renderManager.setCamera(camera, false, buff.getWidth(), buff.getHeight()); // Enable depth test/write for intermediate passes if material needs it mat.getAdditionalRenderState().setDepthTest(true); mat.getAdditionalRenderState().setDepthWrite(true); @@ -431,46 +434,47 @@ private void renderFilterChain(Renderer r, FrameBuffer sceneFb) { @Override public void postFrame(FrameBuffer out) { + try { + FrameBuffer sceneBuffer = renderFrameBuffer; + if (renderFrameBufferMS != null && !renderer.getCaps().contains(Caps.OpenGL32)) { + renderer.copyFrameBuffer(renderFrameBufferMS, renderFrameBuffer, true, true); + } else if (renderFrameBufferMS != null) { + sceneBuffer = renderFrameBufferMS; + } - FrameBuffer sceneBuffer = renderFrameBuffer; - if (renderFrameBufferMS != null && !renderer.getCaps().contains(Caps.OpenGL32)) { - renderer.copyFrameBuffer(renderFrameBufferMS, renderFrameBuffer, true, true); - } else if (renderFrameBufferMS != null) { - sceneBuffer = renderFrameBufferMS; - } - - // Execute the filter chain - renderFilterChain(renderer, sceneBuffer); - - // Restore the original output framebuffer for the viewport - renderer.setFrameBuffer(outputBuffer); - - // viewport can be null if no filters are enabled - if (viewPort != null) { - renderManager.setCamera(viewPort.getCamera(), false); + // Execute the filter chain + renderFilterChain(renderer, sceneBuffer); + } finally { + // Later processors and the translucent bucket render to the original + // physical target with the untouched logical scene camera. + renderer.setFrameBuffer(outputBuffer); + restoreSceneCamera(); + if (viewPort != null) { + renderManager.setCamera(viewPort.getCamera(), false, + originalWidth, originalHeight); + } } } @Override public void preFrame(float tpf) { if (filters.isEmpty() || lastFilterIndex == -1) { - // If no filters are enabled, restore the camera's original viewport - // and output framebuffer to bypass the post-processor. + // If no filters are enabled, restore the output framebuffer and + // bypass the post-processor. if (cameraInit) { - viewPort.getCamera().resize(originalWidth, originalHeight, true); - viewPort.getCamera().setViewPort(left, right, bottom, top); + restoreSceneCamera(); viewPort.setOutputFrameBuffer(outputBuffer); cameraInit = false; } } else { setupViewPortFrameBuffer(); - // If in a multi-view situation, resize the camera to the viewport size - // so that the back buffer is rendered correctly for filtering. + // A multi-view FPP owns a cropped physical framebuffer. Render the + // scene through a logical-sized camera whose viewport covers that + // whole framebuffer, without modifying the original camera object. if (multiView) { - viewPort.getCamera().resize(width, height, false); - viewPort.getCamera().setViewPort(0, 1, 0, 1); - viewPort.getCamera().update(); - renderManager.setCamera(viewPort.getCamera(), false); + installMultiViewRenderCamera(); + } else { + restoreSceneCamera(); } } @@ -529,9 +533,8 @@ private void updateLastFilterIndex() { @Override public void cleanup() { if (viewPort != null) { - // Reset the viewport camera and output framebuffer to their initial values - viewPort.getCamera().resize(originalWidth, originalHeight, true); - viewPort.getCamera().setViewPort(left, right, bottom, top); + // Reset FPP render state and restore the original framebuffer. + restoreSceneCamera(); viewPort.setOutputFrameBuffer(outputBuffer); viewPort = null; @@ -563,11 +566,10 @@ public void setProfiler(AppProfiler profiler) { */ @Override public void reshape(ViewPort vp, int w, int h) { + restoreSceneCamera(); Camera cam = vp.getCamera(); - // This sets the camera viewport to its full extent (0-1) for rendering to the FPP's internal buffer. - cam.setViewPort(left, right, bottom, top); - // Resizing the camera to fit the new viewport and saving original dimensions - cam.resize(w, h, true); + cameraWidth = cam.getWidth(); + cameraHeight = cam.getHeight(); left = cam.getViewPortLeft(); right = cam.getViewPortRight(); top = cam.getViewPortTop(); @@ -580,13 +582,13 @@ public void reshape(ViewPort vp, int w, int h) { height = (int) (h * (Math.abs(bottom - top))); width = Math.max(1, width); height = Math.max(1, height); + logicalViewWidth = Math.max(1, (int) (cameraWidth * Math.abs(right - left))); + logicalViewHeight = Math.max(1, (int) (cameraHeight * Math.abs(top - bottom))); // Test if original dimensions differ from actual viewport dimensions. // If they are different, we are in a multiview situation, and the // camera must be handled differently (e.g., resized to the sub-viewport). - if (originalWidth != width || originalHeight != height) { - multiView = true; - } + multiView = originalWidth != width || originalHeight != height; cameraInit = true; computeDepth = false; @@ -647,6 +649,35 @@ public void reshape(ViewPort vp, int w, int h) { setupViewPortFrameBuffer(); } + private void installMultiViewRenderCamera() { + restoreSceneCamera(); + sceneCamera = viewPort.getCamera(); + multiViewRenderCamera.copyFrom(sceneCamera); + multiViewRenderCamera.resize(logicalViewWidth, logicalViewHeight, false); + multiViewRenderCamera.setViewPort(0f, 1f, 0f, 1f); + viewPort.setCamera(multiViewRenderCamera); + multiViewRenderCameraInstalled = true; + } + + Camera prepareProcessingCamera(int logicalWidth, int logicalHeight, + float viewportLeft, float viewportRight, float viewportBottom, float viewportTop) { + Camera sourceCamera = multiViewRenderCameraInstalled ? sceneCamera : viewPort.getCamera(); + processingCamera.copyFrom(sourceCamera); + processingCamera.resize(Math.max(logicalWidth, 1), Math.max(logicalHeight, 1), false); + processingCamera.setViewPort(viewportLeft, viewportRight, viewportBottom, viewportTop); + return processingCamera; + } + + private void restoreSceneCamera() { + if (!multiViewRenderCameraInstalled) { + return; + } + if (viewPort != null && viewPort.getCamera() == multiViewRenderCamera) { + viewPort.setCamera(sceneCamera); + } + multiViewRenderCameraInstalled = false; + } + private void cleanupFilterResources() { for (Filter filter : filters.getArray()) { filter.cleanup(renderer); diff --git a/jme3-core/src/main/java/com/jme3/renderer/RenderManager.java b/jme3-core/src/main/java/com/jme3/renderer/RenderManager.java index 5b9eb324f3..ccf683358b 100644 --- a/jme3-core/src/main/java/com/jme3/renderer/RenderManager.java +++ b/jme3-core/src/main/java/com/jme3/renderer/RenderManager.java @@ -115,6 +115,8 @@ public class RenderManager { private Camera prevCam = null; private int prevViewportWidth = -1; private int prevViewportHeight = -1; + private int defaultFramebufferWidth = -1; + private int defaultFramebufferHeight = -1; private Material forcedMaterial = null; private String forcedTechnique = null; private RenderState forcedRenderState = null; @@ -437,10 +439,11 @@ public List getPostViews() { * @param cam the Camera to use for rendering (alias created) * @return a new instance */ - public ViewPort createPreView(String viewName, Camera cam) { - ViewPort vp = new ViewPort(viewName, cam); - preViewPorts.add(vp); - return vp; + public ViewPort createPreView(String viewName, Camera cam) { + ViewPort vp = new ViewPort(viewName, cam); + applyDefaultFramebufferSize(vp); + preViewPorts.add(vp); + return vp; } /** @@ -453,10 +456,11 @@ public ViewPort createPreView(String viewName, Camera cam) { * @param cam the Camera to use for rendering (alias created) * @return a new instance */ - public ViewPort createMainView(String viewName, Camera cam) { - ViewPort vp = new ViewPort(viewName, cam); - viewPorts.add(vp); - return vp; + public ViewPort createMainView(String viewName, Camera cam) { + ViewPort vp = new ViewPort(viewName, cam); + applyDefaultFramebufferSize(vp); + viewPorts.add(vp); + return vp; } /** @@ -468,11 +472,18 @@ public ViewPort createMainView(String viewName, Camera cam) { * @param cam the Camera to use for rendering (alias created) * @return a new instance */ - public ViewPort createPostView(String viewName, Camera cam) { - ViewPort vp = new ViewPort(viewName, cam); - postViewPorts.add(vp); - return vp; - } + public ViewPort createPostView(String viewName, Camera cam) { + ViewPort vp = new ViewPort(viewName, cam); + applyDefaultFramebufferSize(vp); + postViewPorts.add(vp); + return vp; + } + + private void applyDefaultFramebufferSize(ViewPort vp) { + if (defaultFramebufferWidth > 0 && defaultFramebufferHeight > 0) { + vp.setRenderTargetSize(defaultFramebufferWidth, defaultFramebufferHeight); + } + } private void notifyReshape(ViewPort vp, int w, int h) { vp.setRenderTargetSize(w, h); @@ -524,6 +535,8 @@ public void notifyReshape(int logicalWidth, int logicalHeight, int framebufferWi prevCam = null; int surfaceWidth = Math.max(framebufferWidth, 1); int surfaceHeight = Math.max(framebufferHeight, 1); + defaultFramebufferWidth = surfaceWidth; + defaultFramebufferHeight = surfaceHeight; reshapeViewPorts(preViewPorts, logicalWidth, logicalHeight, surfaceWidth, surfaceHeight); reshapeViewPorts(viewPorts, logicalWidth, logicalHeight, surfaceWidth, surfaceHeight); reshapeViewPorts(postViewPorts, logicalWidth, logicalHeight, surfaceWidth, surfaceHeight); @@ -1366,7 +1379,20 @@ public void setCamera(Camera cam, boolean ortho) { setCamera(cam, ortho, cam.getWidth(), cam.getHeight()); } - private void setCamera(Camera cam, boolean ortho, int targetWidth, int targetHeight) { + /** + * Sets the camera while applying its normalized viewport to an explicit + * render-target size. + * + *

This overload is intended for rendering paths where camera dimensions + * are logical coordinates but the framebuffer uses a different physical + * size, such as HiDPI, supersampling, and post-processing.

+ * + * @param cam the camera to set + * @param ortho true to use GUI orthographic projection + * @param targetWidth physical render-target width + * @param targetHeight physical render-target height + */ + public void setCamera(Camera cam, boolean ortho, int targetWidth, int targetHeight) { // Tell the light filter which camera to use for filtering. if (lightFilter != null) { lightFilter.setCamera(cam); diff --git a/jme3-core/src/main/java/com/jme3/renderer/ViewPort.java b/jme3-core/src/main/java/com/jme3/renderer/ViewPort.java index 75ae95c998..dc3c4c67d2 100644 --- a/jme3-core/src/main/java/com/jme3/renderer/ViewPort.java +++ b/jme3-core/src/main/java/com/jme3/renderer/ViewPort.java @@ -73,7 +73,7 @@ public class ViewPort { /** * Camera used for rendering. */ - protected final Camera cam; + protected Camera cam; /** * Geometries for rendering, sorted. */ @@ -125,10 +125,12 @@ public class ViewPort { * * * @param name The name of the viewport. Used for debugging only. - * @param cam The camera through which the viewport is rendered. The camera - * cannot be swapped to a different one after creating the viewport. + * @param cam The camera through which the viewport is rendered (not null). */ public ViewPort(String name, Camera cam) { + if (cam == null) { + throw new IllegalArgumentException("Camera cannot be null."); + } this.name = name; this.cam = cam; } @@ -382,6 +384,22 @@ public Camera getCamera() { return cam; } + /** + * Replaces the camera used by this viewport. + * + *

Scene processors can use this method to install a temporary camera + * for a render pass. They are responsible for restoring the previous + * camera when the pass completes.

+ * + * @param camera the camera to use (not null) + */ + public void setCamera(Camera camera) { + if (camera == null) { + throw new IllegalArgumentException("Camera cannot be null."); + } + this.cam = camera; + } + /** * Internal use only. * diff --git a/jme3-core/src/test/java/com/jme3/post/FilterPostProcessorTest.java b/jme3-core/src/test/java/com/jme3/post/FilterPostProcessorTest.java index 624b2f6865..6231fc770a 100644 --- a/jme3-core/src/test/java/com/jme3/post/FilterPostProcessorTest.java +++ b/jme3-core/src/test/java/com/jme3/post/FilterPostProcessorTest.java @@ -33,6 +33,9 @@ import com.jme3.asset.AssetManager; import com.jme3.material.Material; +import com.jme3.math.Matrix4f; +import com.jme3.math.Quaternion; +import com.jme3.math.Vector3f; import com.jme3.renderer.Camera; import com.jme3.renderer.RenderManager; import com.jme3.renderer.Renderer; @@ -41,12 +44,132 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; class FilterPostProcessorTest { @Test - void initializesFromViewportRenderTargetSize() { + void processingCameraCopiesSceneStateWithoutMutatingIt() { RenderManager renderManager = new RenderManager(new NullRenderer()); + Camera sceneCamera = new Camera(320, 240); + sceneCamera.setParallelProjection(true); + sceneCamera.setFrustum(-5f, 40f, -7f, 9f, 6f, -4f); + sceneCamera.setLocation(new Vector3f(3f, 7f, 11f)); + sceneCamera.setRotation(new Quaternion().fromAngles(0.2f, -0.4f, 0.1f)); + + ViewPort viewPort = renderManager.createMainView("main", sceneCamera); + FilterPostProcessor processor = new FilterPostProcessor(null); + viewPort.addProcessor(processor); + renderManager.notifyReshape(320, 240, 640, 480); + + Matrix4f sceneView = sceneCamera.getViewMatrix().clone(); + Matrix4f sceneProjection = sceneCamera.getProjectionMatrix().clone(); + Camera processingCamera = processor.prepareProcessingCamera(320, 240, 0f, 1f, 0f, 1f); + + assertNotSame(sceneCamera, processingCamera); + assertEquals(sceneCamera.getLocation(), processingCamera.getLocation()); + assertEquals(sceneCamera.getRotation(), processingCamera.getRotation()); + assertEquals(sceneView, processingCamera.getViewMatrix()); + assertEquals(sceneProjection, processingCamera.getProjectionMatrix()); + assertEquals(sceneCamera.getFrustumNear(), processingCamera.getFrustumNear()); + assertEquals(sceneCamera.getFrustumFar(), processingCamera.getFrustumFar()); + assertEquals(sceneCamera.isParallelProjection(), processingCamera.isParallelProjection()); + assertEquals(320, processingCamera.getWidth()); + assertEquals(240, processingCamera.getHeight()); + assertEquals(320, sceneCamera.getWidth()); + assertEquals(240, sceneCamera.getHeight()); + assertEquals(sceneView, sceneCamera.getViewMatrix()); + assertEquals(sceneProjection, sceneCamera.getProjectionMatrix()); + } + + @Test + void lateCreatedMultiViewFppInheritsPhysicalDefaultFramebufferSize() { + RenderManager renderManager = new RenderManager(new NullRenderer()); + renderManager.notifyReshape(320, 240, 640, 480); + + Camera sceneCamera = new Camera(320, 240); + sceneCamera.setViewPort(0.5f, 1f, 0f, 1f); + ViewPort lateView = renderManager.createMainView("late right", sceneCamera); + RecordingFilter filter = new RecordingFilter(); + FilterPostProcessor processor = new FilterPostProcessor(null); + processor.addFilter(filter); + + processor.initialize(renderManager, lateView); + + assertEquals(320, filter.width); + assertEquals(480, filter.height); + assertEquals(320, filter.cameraWidthDuringInit); + assertEquals(240, filter.cameraHeightDuringInit); + assertEquals(320, sceneCamera.getWidth()); + assertEquals(240, sceneCamera.getHeight()); + } + + @Test + void multiViewRightHalfAtTwoXDisplayScaleUsesLogicalCameraAndPhysicalFppTarget() { + RecordingRenderer renderer = new RecordingRenderer(); + RenderManager renderManager = new RenderManager(renderer); + Camera sceneCamera = new Camera(320, 240); + sceneCamera.setViewPort(0.5f, 1f, 0f, 1f); + + ViewPort viewPort = renderManager.createMainView("right", sceneCamera); + RecordingFilter filter = new RecordingFilter(); + FilterPostProcessor processor = new FilterPostProcessor(null); + processor.addFilter(filter); + viewPort.addProcessor(processor); + + renderManager.notifyReshape(320, 240, 640, 480); + + Matrix4f sceneProjection = sceneCamera.getProjectionMatrix().clone(); + processor.preFrame(0f); + + Camera renderCamera = viewPort.getCamera(); + assertNotSame(sceneCamera, renderCamera); + assertEquals(160, renderCamera.getWidth()); + assertEquals(240, renderCamera.getHeight()); + assertEquals(0f, renderCamera.getViewPortLeft()); + assertEquals(1f, renderCamera.getViewPortRight()); + assertEquals(0f, renderCamera.getViewPortBottom()); + assertEquals(1f, renderCamera.getViewPortTop()); + assertEquals(sceneProjection, renderCamera.getProjectionMatrix()); + + assertEquals(320, sceneCamera.getWidth()); + assertEquals(240, sceneCamera.getHeight()); + assertEquals(0.5f, sceneCamera.getViewPortLeft()); + assertEquals(1f, sceneCamera.getViewPortRight()); + assertEquals(sceneProjection, sceneCamera.getProjectionMatrix()); + assertEquals(320, filter.width); + assertEquals(480, filter.height); + assertEquals(320, viewPort.getRenderTargetWidth()); + assertEquals(480, viewPort.getRenderTargetHeight()); + + renderManager.applyViewPort(viewPort); + assertEquals(0, renderer.viewPortX); + assertEquals(0, renderer.viewPortY); + assertEquals(320, renderer.viewPortWidth); + assertEquals(480, renderer.viewPortHeight); + + // Avoid rendering the test filter's null material, then exercise the + // normal post-frame restoration used before the translucent bucket. + filter.setEnabled(false); + processor.postFrame(null); + + assertSame(sceneCamera, viewPort.getCamera()); + assertEquals(320, renderer.viewPortX); + assertEquals(0, renderer.viewPortY); + assertEquals(320, renderer.viewPortWidth); + assertEquals(480, renderer.viewPortHeight); + assertEquals(320, sceneCamera.getWidth()); + assertEquals(240, sceneCamera.getHeight()); + assertEquals(0.5f, sceneCamera.getViewPortLeft()); + assertEquals(1f, sceneCamera.getViewPortRight()); + assertEquals(sceneProjection, sceneCamera.getProjectionMatrix()); + } + + @Test + void fullViewAtDisplayScaleUsesPhysicalFppTargetAndLogicalCamera() { + RecordingRenderer renderer = new RecordingRenderer(); + RenderManager renderManager = new RenderManager(renderer); Camera camera = new Camera(320, 240); ViewPort viewPort = renderManager.createMainView("main", camera); renderManager.notifyReshape(320, 240, 640, 480); @@ -60,23 +183,65 @@ void initializesFromViewportRenderTargetSize() { assertEquals(640, filter.width); assertEquals(480, filter.height); + assertEquals(320, filter.cameraWidthDuringInit); + assertEquals(240, filter.cameraHeightDuringInit); + assertSame(camera, viewPort.getCamera()); + assertEquals(320, camera.getWidth()); + assertEquals(240, camera.getHeight()); + assertEquals(640, viewPort.getRenderTargetWidth()); + assertEquals(480, viewPort.getRenderTargetHeight()); + + renderManager.applyViewPort(viewPort); + assertEquals(640, renderer.viewPortWidth); + assertEquals(480, renderer.viewPortHeight); renderManager.notifyReshape(320, 240, 800, 600); assertEquals(800, filter.width); assertEquals(600, filter.height); + assertEquals(320, filter.cameraWidthDuringInit); + assertEquals(240, filter.cameraHeightDuringInit); + assertEquals(320, camera.getWidth()); + assertEquals(240, camera.getHeight()); assertEquals(1, filter.cleanupCount); + + filter.setEnabled(false); + processor.postFrame(null); + + assertEquals(320, camera.getWidth()); + assertEquals(240, camera.getHeight()); + assertEquals(800, renderer.viewPortWidth); + assertEquals(600, renderer.viewPortHeight); + } + + private static class RecordingRenderer extends NullRenderer { + private int viewPortX; + private int viewPortY; + private int viewPortWidth; + private int viewPortHeight; + + @Override + public void setViewPort(int x, int y, int width, int height) { + viewPortX = x; + viewPortY = y; + viewPortWidth = width; + viewPortHeight = height; + } } private static class RecordingFilter extends Filter { private int width; private int height; + private int cameraWidthDuringInit; + private int cameraHeightDuringInit; private int cleanupCount; @Override protected void initFilter(AssetManager manager, RenderManager renderManager, ViewPort vp, int w, int h) { width = w; height = h; + cameraWidthDuringInit = vp.getCamera().getWidth(); + cameraHeightDuringInit = vp.getCamera().getHeight(); } @Override diff --git a/jme3-core/src/test/java/com/jme3/renderer/RenderManagerViewPortSizeTest.java b/jme3-core/src/test/java/com/jme3/renderer/RenderManagerViewPortSizeTest.java new file mode 100644 index 0000000000..8219abccc0 --- /dev/null +++ b/jme3-core/src/test/java/com/jme3/renderer/RenderManagerViewPortSizeTest.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026 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.renderer; + +import com.jme3.system.NullRenderer; +import com.jme3.texture.FrameBuffer; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RenderManagerViewPortSizeTest { + + @Test + void viewsCreatedBeforeFirstReshapeFallBackToCameraSize() { + RenderManager renderManager = new RenderManager(new NullRenderer()); + + assertTargetSize(renderManager.createPreView("pre", new Camera(320, 240)), 320, 240); + assertTargetSize(renderManager.createMainView("main", new Camera(400, 300)), 400, 300); + assertTargetSize(renderManager.createPostView("post", new Camera(500, 400)), 500, 400); + } + + @Test + void viewsCreatedAfterReshapeInheritDefaultFramebufferSize() { + RenderManager renderManager = new RenderManager(new NullRenderer()); + renderManager.notifyReshape(320, 240, 640, 480); + + Camera preCamera = new Camera(320, 240); + Camera mainCamera = new Camera(320, 240); + Camera postCamera = new Camera(320, 240); + + assertTargetSize(renderManager.createPreView("pre", preCamera), 640, 480); + assertTargetSize(renderManager.createMainView("main", mainCamera), 640, 480); + assertTargetSize(renderManager.createPostView("post", postCamera), 640, 480); + assertEquals(320, mainCamera.getWidth()); + assertEquals(240, mainCamera.getHeight()); + } + + @Test + void explicitFramebufferOverridesInheritedDefaultFramebufferSize() { + RenderManager renderManager = new RenderManager(new NullRenderer()); + renderManager.notifyReshape(320, 240, 640, 480); + ViewPort viewPort = renderManager.createMainView("main", new Camera(320, 240)); + + viewPort.setOutputFrameBuffer(new FrameBuffer(256, 128, 1)); + + assertTargetSize(viewPort, 256, 128); + } + + @Test + void laterReshapeUpdatesExistingViewsAndNewViewDefaults() { + RenderManager renderManager = new RenderManager(new NullRenderer()); + renderManager.notifyReshape(320, 240, 640, 480); + ViewPort existingView = renderManager.createMainView("existing", new Camera(320, 240)); + + renderManager.notifyReshape(320, 240, 800, 600); + ViewPort lateView = renderManager.createMainView("late", new Camera(320, 240)); + + assertTargetSize(existingView, 800, 600); + assertTargetSize(lateView, 800, 600); + } + + private static void assertTargetSize(ViewPort viewPort, int expectedWidth, int expectedHeight) { + assertEquals(expectedWidth, viewPort.getRenderTargetWidth()); + assertEquals(expectedHeight, viewPort.getRenderTargetHeight()); + } +} diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/java/org/jmonkeyengine/screenshottests/post/TestMultiViewDisplayScale.java b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/java/org/jmonkeyengine/screenshottests/post/TestMultiViewDisplayScale.java new file mode 100644 index 0000000000..d281e6cf6b --- /dev/null +++ b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/java/org/jmonkeyengine/screenshottests/post/TestMultiViewDisplayScale.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER + * OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.jmonkeyengine.screenshottests.post; + +import org.jmonkeyengine.screenshottests.scenarios.post.ScenarioMultiViewDisplayScale; +import org.jmonkeyengine.screenshottests.testframework.desktop.DesktopRunner; +import org.jmonkeyengine.screenshottests.testframework.desktop.ScreenshotTestDesktopBase; +import org.junit.jupiter.api.Test; + +/** + * Screenshot regression for an FPP split screen at 2x display scale. + */ +public class TestMultiViewDisplayScale extends ScreenshotTestDesktopBase { + + @Test + public void testMultiViewDisplayScale() { + ScenarioMultiViewDisplayScale.testMultiViewDisplayScale().run(new DesktopRunner()); + } +} diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-shared/src/main/java/org/jmonkeyengine/screenshottests/scenarios/post/ScenarioMultiViewDisplayScale.java b/jme3-screenshot-tests/jme3-screenshot-tests-shared/src/main/java/org/jmonkeyengine/screenshottests/scenarios/post/ScenarioMultiViewDisplayScale.java new file mode 100644 index 0000000000..20335b61c9 --- /dev/null +++ b/jme3-screenshot-tests/jme3-screenshot-tests-shared/src/main/java/org/jmonkeyengine/screenshottests/scenarios/post/ScenarioMultiViewDisplayScale.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026 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.scenarios.post; + +import com.jme3.app.Application; +import com.jme3.app.SimpleApplication; +import com.jme3.app.state.BaseAppState; +import com.jme3.material.Material; +import com.jme3.math.ColorRGBA; +import com.jme3.math.Vector3f; +import com.jme3.post.FilterPostProcessor; +import com.jme3.post.filters.ColorOverlayFilter; +import com.jme3.renderer.Camera; +import com.jme3.renderer.ViewPort; +import com.jme3.scene.Geometry; +import com.jme3.scene.shape.Box; +import org.jmonkeyengine.screenshottests.testframework.ScreenshotTest; +import org.jmonkeyengine.screenshottests.testframework.TestResolution; + +/** + * Split-screen FPP scenario rendered with a deterministic 2x display scale. + */ +public final class ScenarioMultiViewDisplayScale { + + private static final int LOGICAL_WIDTH = 320; + private static final int LOGICAL_HEIGHT = 240; + private static final float DISPLAY_SCALE = 2f; + + private ScenarioMultiViewDisplayScale() { + } + + public static ScreenshotTest testMultiViewDisplayScale() { + return new ScreenshotTest(new BaseAppState() { + private ViewPort rightView; + private FilterPostProcessor leftProcessor; + private FilterPostProcessor rightProcessor; + + @Override + protected void initialize(Application application) { + SimpleApplication app = (SimpleApplication) application; + + Camera leftCamera = app.getCamera(); + leftCamera.setViewPort(0f, 0.5f, 0f, 1f); + leftCamera.setFrustumPerspective(45f, 2f / 3f, 1f, 100f); + leftCamera.setLocation(new Vector3f(0f, 0f, 8f)); + leftCamera.lookAt(Vector3f.ZERO, Vector3f.UNIT_Y); + + Camera rightCamera = leftCamera.clone(); + rightCamera.setViewPort(0.5f, 1f, 0f, 1f); + + ViewPort leftView = app.getViewPort(); + leftView.setBackgroundColor(new ColorRGBA(0.04f, 0.04f, 0.08f, 1f)); + leftView.setClearFlags(true, true, true); + + rightView = app.getRenderManager().createMainView("right", rightCamera); + rightView.setBackgroundColor(new ColorRGBA(0.04f, 0.04f, 0.08f, 1f)); + rightView.setClearFlags(true, true, true); + rightView.attachScene(app.getRootNode()); + + attachGeometry(app); + + leftProcessor = new FilterPostProcessor(app.getAssetManager()); + leftProcessor.addFilter(new ColorOverlayFilter( + new ColorRGBA(1f, 0.25f, 0.25f, 1f))); + leftView.addProcessor(leftProcessor); + + rightProcessor = new FilterPostProcessor(app.getAssetManager()); + rightProcessor.addFilter(new ColorOverlayFilter( + new ColorRGBA(0.25f, 0.7f, 1f, 1f))); + rightView.addProcessor(rightProcessor); + } + + private void attachGeometry(SimpleApplication app) { + Material white = new Material(app.getAssetManager(), + "Common/MatDefs/Misc/Unshaded.j3md"); + white.setColor("Color", ColorRGBA.White); + + Geometry center = new Geometry("center", new Box(1.25f, 1.25f, 1.25f)); + center.setMaterial(white); + center.rotate(0.35f, 0.55f, 0.1f); + app.getRootNode().attachChild(center); + + Geometry marker = new Geometry("upper marker", new Box(0.35f, 0.35f, 0.35f)); + marker.setMaterial(white.clone()); + marker.setLocalTranslation(0.9f, 1.8f, 0f); + app.getRootNode().attachChild(marker); + } + + @Override + protected void cleanup(Application application) { + SimpleApplication app = (SimpleApplication) application; + if (leftProcessor != null) { + app.getViewPort().removeProcessor(leftProcessor); + } + if (rightView != null) { + if (rightProcessor != null) { + rightView.removeProcessor(rightProcessor); + } + app.getRenderManager().removeMainView(rightView); + } + } + + @Override + protected void onEnable() { + } + + @Override + protected void onDisable() { + } + }) + .setTestResolution(new TestResolution(LOGICAL_WIDTH, LOGICAL_HEIGHT)) + .setDisplayScaleMode(DISPLAY_SCALE) + .setFramesToTakeScreenshotsOn(2); + } +} diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-shared/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTest.java b/jme3-screenshot-tests/jme3-screenshot-tests-shared/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTest.java index 0df92c4018..e05ec6d1f9 100644 --- a/jme3-screenshot-tests/jme3-screenshot-tests-shared/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTest.java +++ b/jme3-screenshot-tests/jme3-screenshot-tests-shared/src/main/java/org/jmonkeyengine/screenshottests/testframework/ScreenshotTest.java @@ -97,6 +97,8 @@ public class ScreenshotTest{ TestResolution resolution = new TestResolution(500, 400); + float displayScaleMode = AppSettings.DISPLAY_SCALE_DISABLED; + String baseImageFileName = null; private AppRunner osSpecificRunner; @@ -132,6 +134,21 @@ public ScreenshotTest setTestResolution(TestResolution resolution){ return this; } + /** + * Sets the display-scale mode used by the test context. + * + *

A value greater than {@link AppSettings#DISPLAY_SCALE_DPI_AWARE} + * enables deterministic emulated display scaling, independent of the + * monitor attached to the test machine.

+ * + * @param displayScaleMode the desired display-scale mode + * @return this test configuration + */ + public ScreenshotTest setDisplayScaleMode(float displayScaleMode) { + this.displayScaleMode = displayScaleMode; + 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 @@ -146,6 +163,7 @@ public void run(AppRunner osSpecificRunner){ this.osSpecificRunner = osSpecificRunner; AppSettings settings = new AppSettings(true); settings.setResolution(resolution.getWidth(), resolution.getHeight()); + settings.setDisplayScaleMode(displayScaleMode); settings.setAudioRenderer(null); // Disable audio (for headless) settings.setUseInput(false); //while it will run with inputs on it causes non-fatal errors. settings.setRenderer(AppSettings.LWJGL_OPENGL45); diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-shared/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestDriver.java b/jme3-screenshot-tests/jme3-screenshot-tests-shared/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestDriver.java index 95c066c9c1..3cc20a29bd 100644 --- a/jme3-screenshot-tests/jme3-screenshot-tests-shared/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestDriver.java +++ b/jme3-screenshot-tests/jme3-screenshot-tests-shared/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestDriver.java @@ -38,6 +38,7 @@ import com.jme3.app.state.BaseAppState; import com.jme3.math.FastMath; import com.jme3.system.AppSettings; +import com.jme3.system.DisplayScaleUtils; import com.jme3.system.JmeContext; import com.jme3.texture.FrameBuffer; import com.jme3.texture.Image; @@ -129,6 +130,11 @@ public void update(float tpf){ AppSettings settings = app.getContext().getSettings(); int width = settings.getWidth(); int height = settings.getHeight(); + float displayScaleMode = settings.getDisplayScaleMode(); + if (DisplayScaleUtils.isEmulatedScaleMode(displayScaleMode)) { + width = Math.max(Math.round(app.getContext().getFramebufferWidth() * displayScaleMode), 1); + height = Math.max(Math.round(app.getContext().getFramebufferHeight() * displayScaleMode), 1); + } Texture2D renderTexture = new Texture2D(width, height, Image.Format.RGBA8); renderTexture.setMinFilter(Texture.MinFilter.BilinearNearestMipMap); renderTexture.setMagFilter(Texture.MagFilter.Bilinear); From 5dda6346ede37f703308de765e40b6ca0fcd451b Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 21 Jul 2026 15:16:11 +0200 Subject: [PATCH 4/5] fix nativeimage-plugin signing --- jme3-nativeimage-plugin/build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jme3-nativeimage-plugin/build.gradle b/jme3-nativeimage-plugin/build.gradle index 094521caf6..92c21c2b63 100644 --- a/jme3-nativeimage-plugin/build.gradle +++ b/jme3-nativeimage-plugin/build.gradle @@ -140,12 +140,12 @@ tasks.register('install') { } signing { - def signingKey = findProperty('signingKey') - def signingPassword = findProperty('signingPassword') + def signingKey = gradle.rootProject.findProperty('signingKey') + def signingPassword = gradle.rootProject.findProperty('signingPassword') useInMemoryPgpKeys(signingKey, signingPassword) sign publishing.publications } tasks.withType(Sign).configureEach { - onlyIf { hasProperty('signingKey') } + onlyIf { gradle.rootProject.hasProperty('signingKey') } } From 6a9f25043035d0a5c2dc7a5751f65e224f710f71 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 21 Jul 2026 17:49:03 +0200 Subject: [PATCH 5/5] add missing screenshot --- ...DisplayScale.testMultiViewDisplayScale_f2.png | Bin 0 -> 5927 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/main/resources/org.jmonkeyengine.screenshottests.post.TestMultiViewDisplayScale.testMultiViewDisplayScale_f2.png diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/main/resources/org.jmonkeyengine.screenshottests.post.TestMultiViewDisplayScale.testMultiViewDisplayScale_f2.png b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/main/resources/org.jmonkeyengine.screenshottests.post.TestMultiViewDisplayScale.testMultiViewDisplayScale_f2.png new file mode 100644 index 0000000000000000000000000000000000000000..b8d46f14abdf6aace5b7e863bdd05cc1bdcdad61 GIT binary patch literal 5927 zcmeHLdr%YC8b6zaBp|zlhk&aa6Ro1PwX_Y2u|Ob*h#2rup}q)hM+n?$Gd2hchQ&x? zYg_F&R&7Co6_JM!uh&Wo4GZbR&R9Xht1aSY#<>tc*=ZGWi;ue@yLPVkPG{Ob@A&7* zcYfdFcYbHjm+#$F)oR!W^#K6DYZBu(0KmZkfJh;q{w3o2z%2lR@2rWB-BhsScJB`# zys>t7!u8KT!#Yzpo|InxY1ImQ+-RkFa(CK=m%eX$^XsTwOTxSN-vC2D7f&^|KKd>O zTKL;k@x$drV)1A!@5E_=zuE^9zTaaIhL-}~w+fHuj9yv8D_=S~9p+Xc z8eCR6euZ&5u1VJ#t@9Z-*6Pf*S-*YA0zct4@3k3bEO@XT364PoP`Hei1h?bkRr;(LKrK~YX z)fHYM+j`GK$c`V%}_=dc%iEjny{Nmv0O*A272~tBDHU0 ziWA9T#~CGeth$6q#u82_n3WV1_qH8a>Cd<&ZE3nhM?8m`hoR~iYnI$muqzx}*yJVZ7&xStHFkob>`p3*H3pGIFs4^A6xpDGG;X$>jmZm7 zv1R+PD~Rxe&o;C4wFzA1#_k%Xu)Y0|wJtM{-7DUOzlv`AeJ{Uhi*jruk9-{qRcAz9 z1r=S2=1;)3dM%-h$2rM)H4 zL=V@OS`7cA&YSo~9!^;I;!t!|$?gcKDP6$tPxA{Ws%4!G3qi;z0j5SEe!*qqkIh16 z??GtreCEVb*6-_he(s5{4ly~HLdhHG&hdE+RQg}gs@RFQSm>L0zUIhRpR(!CBDbS& zjy@tsodtPy=3gKW~(^? zHS~(6g4oJmCSFC=6>JVA{H7%Cn4P7&6QpVIeUIrz$!B3pN+3hGg1fB>n$WO8v+*8u zN3)1ArgH0hsh>(&l2G*}W8YHNZ8xZkXxS}gD68bBpLblE0W~F>y!tczB8X?m?%nw+78!kWEr{w zS!-kSP?kP9krr`IH6MgHEnsRiR^Y1T_h{I3he1Ym(#NM9|@dzKBlk{FkrQ7vGCX0rGDkbDubg1+DSDkx75 zxlSWK+7HDAc!ow%t^(F`22R;$0x_9}rd9{yFN~PM&~S;sb&TKbLp`Hk(cnJ{L{SpU(gdew5PSq*ddIPeJhhEhah{=jbkh>Mxk`lV)OWPL1X@R}Chwla59nc~t%ku}2Owlx7*UlRyjWo{~raK?Oav!2TgeoTOhLD;nOC00xroi83}54J4{fCpJjtVKn=sJb$H}&(E5R=zSYp#f zdh22Sj9hPpzE1)9+3osSkIkQG< z71t+hlmt`R7R$%r)?#c!ktiH`ETyv1TbMAQm6 zs9^!vQ-_2XZE#D4@6#867sZ=GqbuD~;g-^+Mb{d9$GaiUV0CPJA-jy_^lG%05a>#< z{3?ZD&yiE~U))&m_hX1`TaA)`zuCy0lz<;Mn9zpKO2fM-|0=!OBAC6IrW?z1IWl;X z)x%nw50(_U-h^4>z^((x^7>9`DVEoi=>=U`FDI$@kmu>F(1vHgiVdbYr>;ZBC3(2x zD2*I7+wyg0$4o4}|0K8-EFYt0V%mZ;G?9V$K$1W#b`mdd6$IQ#2D{c`dtE2_V6vA! zbe`mLccrO9Bhh}2%%O5QN4AcVwu%Tzq|)T;iiWNTF}>@!(W`Kn7Pq$|K?B0K+6(x$*??RV){{TMhPKz#?+8giD?eNdfH;o48L)=BQYET$V$ zf1HA0;k91L*7Jf8bqGgvs`Ch$41b<=&%zl~f%ZT&psYqZxg@F#(9blrpPn1|7Z95` zqTuXw%W-R;pJ)1&e$LTWco(zQdWK#h*=Fh!y|wP{^r*}c2hY+sRQ%lhcvQXRLcp*Pg%|EPOhudpf54e;(1k_sFa2?Qg1v^z%yT0XSH* LN)_J_r{4c>jWg4U literal 0 HcmV?d00001