diff --git a/jme3-core/src/main/resources/Common/ShaderLib/module/pbrlighting/PBRLightingUtils.glsllib b/jme3-core/src/main/resources/Common/ShaderLib/module/pbrlighting/PBRLightingUtils.glsllib index ddd42c08cd..8122f21455 100644 --- a/jme3-core/src/main/resources/Common/ShaderLib/module/pbrlighting/PBRLightingUtils.glsllib +++ b/jme3-core/src/main/resources/Common/ShaderLib/module/pbrlighting/PBRLightingUtils.glsllib @@ -1,5 +1,7 @@ #ifndef __PBR_LIGHT_UTILS_MODULE__ #define __PBR_LIGHT_UTILS_MODULE__ + + vec2 newTexCoord; #import "Common/ShaderLib/Math.glsllib" #import "Common/ShaderLib/PBR.glsllib" @@ -33,7 +35,7 @@ varying vec4 wTangent; #endif - #if defined(ENABLE_PBRLightingUtils_readPBRSurface)||defined(ENABLE_PBRLightingUtils_getTexCoord) + #if defined(ENABLE_PBRLightingUtils_readPBRSurface)||defined(ENABLE_PBRLightingUtils_getTexCoord)||defined(EXPOSUREMAP) varying vec2 texCoord; #ifdef SEPARATE_TEXCOORD varying vec2 texCoord2; @@ -138,23 +140,26 @@ #if defined(EXPOSUREMAP) uniform sampler2D m_SunLightExposureMap; #endif - #if defined(USE_VERTEX_COLORS_AS_SUN_EXPOSURE) + #if defined(USE_VERTEX_COLORS_AS_SUN_EXPOSURE) || defined(USE_VERTEX_COLORS_AS_SUN_INTENSITY) varying vec4 vertColors; #endif - #ifdef STATIC_SUN_EXPOSURE + #if defined(STATIC_SUN_EXPOSURE) || defined(STATIC_SUN_INTENSITY) uniform float m_StaticSunIntensity; #endif void PBRLightingUtils_readSunLightExposureParams(inout PBRSurface surface){ surface.exposure = 1.0; //default value - #ifdef EXPOSUREMAP + #ifdef EXPOSUREMAP + #ifndef ENABLE_PBRLightingUtils_readPBRSurface + newTexCoord = texCoord; + #endif surface.exposure *= texture2D(m_SunLightExposureMap, newTexCoord).r; #endif - #ifdef STATIC_SUN_EXPOSURE + #if defined(STATIC_SUN_EXPOSURE) || 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 #endif - #ifdef USE_VERTEX_COLORS_AS_SUN_EXPOSURE + #if defined(USE_VERTEX_COLORS_AS_SUN_EXPOSURE) || defined(USE_VERTEX_COLORS_AS_SUN_INTENSITY) surface.exposure *= vertColors.r; // use red channel of vertexColors for non-uniform sunlighting accross a single model #endif } @@ -321,7 +326,6 @@ } #ifdef ENABLE_PBRLightingUtils_readPBRSurface - vec2 newTexCoord; void PBRLightingUtils_readPBRSurface(inout PBRSurface surface){ @@ -346,6 +350,7 @@ #endif + newTexCoord = texCoord; #if (defined(PARALLAXMAP) || (defined(NORMALMAP_PARALLAX) && defined(NORMALMAP))) if (surface.hasTangents) { vec3 vViewDir = surface.viewDir * surface.tbnMat; @@ -366,11 +371,7 @@ newTexCoord = classicParallaxOffset(m_ParallaxMap, vViewDir, texCoord, m_ParallaxHeight); #endif #endif - } else { - newTexCoord = texCoord; } - #else - newTexCoord = texCoord; #endif #ifdef BASECOLORMAP diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-android/src/androidTest/java/org/jmonkeyengine/screenshottests/android/light/pbr/TestPBRSunlight.java b/jme3-screenshot-tests/jme3-screenshot-tests-android/src/androidTest/java/org/jmonkeyengine/screenshottests/android/light/pbr/TestPBRSunlight.java new file mode 100644 index 0000000000..16ea8777b4 --- /dev/null +++ b/jme3-screenshot-tests/jme3-screenshot-tests-android/src/androidTest/java/org/jmonkeyengine/screenshottests/android/light/pbr/TestPBRSunlight.java @@ -0,0 +1,54 @@ +/* + * 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.android.light.pbr; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.jmonkeyengine.screenshottests.android.android.ScreenshotTestAndroidBase; +import org.jmonkeyengine.screenshottests.scenarios.light.pbr.ScenarioPBRSunlight; +import org.jmonkeyengine.screenshottests.testframework.AndroidRunner; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Runs the same sunlight exposure charts on Android GLES. */ +@RunWith(AndroidJUnit4.class) +public class TestPBRSunlight extends ScreenshotTestAndroidBase { + + @Test + public void testSunlightInputs() { + ScenarioPBRSunlight.testSunlightInputs().run(new AndroidRunner()); + } + + @Test + public void testSunlightParallax() { + ScenarioPBRSunlight.testSunlightParallax().run(new AndroidRunner()); + } +} diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-android/src/main/resources/org.jmonkeyengine.screenshottests.android.light.pbr.TestPBRSunlight.testSunlightInputs_f3.png b/jme3-screenshot-tests/jme3-screenshot-tests-android/src/main/resources/org.jmonkeyengine.screenshottests.android.light.pbr.TestPBRSunlight.testSunlightInputs_f3.png new file mode 100644 index 0000000000..db78372b9a Binary files /dev/null and b/jme3-screenshot-tests/jme3-screenshot-tests-android/src/main/resources/org.jmonkeyengine.screenshottests.android.light.pbr.TestPBRSunlight.testSunlightInputs_f3.png differ diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-android/src/main/resources/org.jmonkeyengine.screenshottests.android.light.pbr.TestPBRSunlight.testSunlightParallax_f3.png b/jme3-screenshot-tests/jme3-screenshot-tests-android/src/main/resources/org.jmonkeyengine.screenshottests.android.light.pbr.TestPBRSunlight.testSunlightParallax_f3.png new file mode 100644 index 0000000000..b8e029c673 Binary files /dev/null and b/jme3-screenshot-tests/jme3-screenshot-tests-android/src/main/resources/org.jmonkeyengine.screenshottests.android.light.pbr.TestPBRSunlight.testSunlightParallax_f3.png differ diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/java/org/jmonkeyengine/screenshottests/light/pbr/TestPBRSunlight.java b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/java/org/jmonkeyengine/screenshottests/light/pbr/TestPBRSunlight.java new file mode 100644 index 0000000000..6a068b11af --- /dev/null +++ b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/java/org/jmonkeyengine/screenshottests/light/pbr/TestPBRSunlight.java @@ -0,0 +1,51 @@ +/* + * 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.light.pbr; + +import org.jmonkeyengine.screenshottests.scenarios.light.pbr.ScenarioPBRSunlight; +import org.jmonkeyengine.screenshottests.testframework.desktop.DesktopRunner; +import org.jmonkeyengine.screenshottests.testframework.desktop.ScreenshotTestDesktopBase; +import org.junit.jupiter.api.Test; + +/** Sunlight exposure reference images for desktop OpenGL and ANGLE. */ +public class TestPBRSunlight extends ScreenshotTestDesktopBase { + + @Test + public void testSunlightInputs() { + ScenarioPBRSunlight.testSunlightInputs().run(new DesktopRunner()); + } + + @Test + public void testSunlightParallax() { + ScenarioPBRSunlight.testSunlightParallax().run(new DesktopRunner()); + } +} diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/java/org/jmonkeyengine/screenshottests/light/pbr/TestPbrSunExposure.java b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/java/org/jmonkeyengine/screenshottests/light/pbr/TestPbrSunExposure.java new file mode 100644 index 0000000000..6705c71ca4 --- /dev/null +++ b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/java/org/jmonkeyengine/screenshottests/light/pbr/TestPbrSunExposure.java @@ -0,0 +1,181 @@ +/* + * 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 org.jmonkeyengine.screenshottests.light.pbr; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.jme3.asset.AssetManager; +import com.jme3.material.Material; +import com.jme3.material.RenderState; +import com.jme3.math.ColorRGBA; +import com.jme3.math.Vector3f; +import com.jme3.renderer.Camera; +import com.jme3.renderer.RenderManager; +import com.jme3.renderer.Renderer; +import com.jme3.scene.Geometry; +import com.jme3.scene.VertexBuffer; +import com.jme3.scene.shape.Quad; +import com.jme3.system.AppSettings; +import com.jme3.texture.FrameBuffer; +import com.jme3.texture.Image; +import com.jme3.texture.Texture; +import com.jme3.texture.Texture2D; +import com.jme3.texture.image.ColorSpace; +import com.jme3.util.BufferUtils; +import java.nio.ByteBuffer; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.jmonkeyengine.screenshottests.testframework.TestContainingApp; +import org.jmonkeyengine.screenshottests.testframework.desktop.DesktopRunner; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Compiles and renders exposure variants on both desktop and ANGLE CI jobs. */ +@Tag("integration") +public class TestPbrSunExposure { + + @Test + public void sunlightInputsRenderExpectedExposure() throws Exception { + CompletableFuture result = new CompletableFuture<>(); + CountDownLatch finished = new CountDownLatch(1); + final TestContainingApp app = new TestContainingApp() { + @Override + public void simpleInitApp() { + try { + verifyExposure(renderManager, assetManager); + result.complete(null); + } catch (Throwable failure) { + result.completeExceptionally(failure); + } finally { + finished.countDown(); + } + } + + @Override + public void handleError(String message, Throwable failure) { + result.completeExceptionally(failure == null ? new AssertionError(message) : failure); + stop(); + finished.countDown(); + } + }; + AppSettings settings = new AppSettings(true); + settings.setResolution(16, 16); + settings.setAudioRenderer(null); + settings.setUseInput(false); + settings.setRenderer(System.getProperty("jme.screenshot.renderer", AppSettings.LWJGL_OPENGL45)); + app.setSettings(settings); + app.setShowSettings(false); + new DesktopRunner().runApplicationUntilScenarioCompletes(app, finished); + result.get(1, TimeUnit.SECONDS); + } + + static void verifyExposure(RenderManager manager, AssetManager assets) { + Renderer renderer = manager.getRenderer(); + renderer.setMainFrameBufferSrgb(false); + renderer.setLinearizeSrgbImages(false); + Camera camera = new Camera(16, 16); + camera.setParallelProjection(true); + camera.setFrustum(0.1f, 10, -1, 1, 1, -1); + camera.setLocation(new Vector3f(0, 0, 2)); + camera.lookAt(Vector3f.ZERO, Vector3f.UNIT_Y); + manager.setCamera(camera, false); + FrameBuffer target = new FrameBuffer(16, 16, 1); + target.setColorBuffer(Image.Format.RGBA8); + target.setDepthBuffer(Image.Format.Depth24); + try { + renderer.setFrameBuffer(target); + for (String variant : new String[]{"static", "vertex", "map", "parallax", "no-tangents", + "module-map", "module-control"}) { + boolean module = variant.startsWith("module"); + Material material = new Material(assets, module + ? "TestMatDefs/PbrSunExposureModule.j3md" : "Common/MatDefs/Light/PBRLighting.j3md"); + if (!module) { + material.setInt("DebugValuesMode", 6); + material.setBoolean("UseSpecularAA", false); + } + material.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); + Geometry geometry = new Geometry("exposure", new Quad(2, 2)); + geometry.move(-1, -1, 0); + geometry.setMaterial(material); + if (variant.equals("static")) { + material.setFloat("StaticSunIntensity", 0.25f); + } else if (variant.equals("vertex")) { + material.setBoolean("UseVertexColorsAsSunIntensity", true); + geometry.getMesh().setBuffer(VertexBuffer.Type.Color, 4, new float[]{ + 0.25f, 0, 0, 1, 0.25f, 0, 0, 1, 0.25f, 0, 0, 1, 0.25f, 0, 0, 1}); + } else if (!variant.equals("module-control")) { + material.setTexture("SunLightExposureMap", exposureMap()); + } + if (variant.equals("parallax") || variant.equals("no-tangents")) { + material.setTexture("ParallaxMap", exposureMap()); + material.setFloat("ParallaxHeight", 0); + if (variant.equals("parallax")) { + geometry.getMesh().setBuffer(VertexBuffer.Type.Tangent, 4, new float[]{ + 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1}); + } + } + renderer.setBackgroundColor(ColorRGBA.Black); + renderer.clearBuffers(true, true, true); + geometry.updateGeometricState(); + manager.renderGeometry(geometry); + ByteBuffer pixels = BufferUtils.createByteBuffer(16 * 16 * 4); + renderer.readFrameBufferWithFormat(target, pixels, Image.Format.RGBA8); + int left = variant.equals("module-control") ? 255 : 64; + int right = variant.equals("static") || variant.equals("vertex") ? 64 + : variant.equals("module-control") ? 255 : 192; + assertPixel(pixels, 4, left, variant); + assertPixel(pixels, 12, right, variant); + } + } finally { + renderer.setFrameBuffer(null); + target.dispose(); + } + } + + private static Texture2D exposureMap() { + ByteBuffer data = BufferUtils.createByteBuffer(8); + data.put(new byte[]{64, 64, 64, (byte) 255, (byte) 192, (byte) 192, (byte) 192, (byte) 255}).flip(); + Texture2D texture = new Texture2D(new Image(Image.Format.RGBA8, 2, 1, data, ColorSpace.Linear)); + texture.setMinFilter(Texture.MinFilter.NearestNoMipMaps); + texture.setMagFilter(Texture.MagFilter.Nearest); + return texture; + } + + private static void assertPixel(ByteBuffer pixels, int x, int expected, String variant) { + int offset = (8 * 16 + x) * 4; + for (int channel = 0; channel < 3; channel++) { + assertEquals(expected, pixels.get(offset + channel) & 255, 1, variant + " channel " + channel); + } + assertEquals(255, pixels.get(offset + 3) & 255, variant + " alpha"); + } +} diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/TestMatDefs/PbrSunExposureModule.frag b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/TestMatDefs/PbrSunExposureModule.frag new file mode 100644 index 0000000000..ba1e9db313 --- /dev/null +++ b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/TestMatDefs/PbrSunExposureModule.frag @@ -0,0 +1,11 @@ +#import "Common/ShaderLib/GLSLCompat.glsllib" +#define ENABLE_PBRLightingUtils_getWorldPosition 1 +#define ENABLE_PBRLightingUtils_getWorldNormal 1 +#define ENABLE_PBRLightingUtils_computeDirectLight 1 +#import "Common/ShaderLib/module/pbrlighting/PBRLightingUtils.glsllib" + +void main() { + PBRSurface surface; + PBRLightingUtils_readSunLightExposureParams(surface); + gl_FragColor = vec4(vec3(surface.exposure), 1.0); +} diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/TestMatDefs/PbrSunExposureModule.j3md b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/TestMatDefs/PbrSunExposureModule.j3md new file mode 100644 index 0000000000..5366767848 --- /dev/null +++ b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/TestMatDefs/PbrSunExposureModule.j3md @@ -0,0 +1,17 @@ +MaterialDef PbrSunExposureModule { + MaterialParameters { + Texture2D SunLightExposureMap + } + Technique { + VertexShader GLSL310 GLSL300 GLSL150: Common/MatDefs/Light/PBRLighting.vert + FragmentShader GLSL310 GLSL300 GLSL150: TestMatDefs/PbrSunExposureModule.frag + WorldParameters { + WorldViewProjectionMatrix + WorldMatrix + NormalMatrix + } + Defines { + EXPOSUREMAP: SunLightExposureMap + } + } +} diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSunlight.testSunlightInputs_f3.png b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSunlight.testSunlightInputs_f3.png new file mode 100644 index 0000000000..9868eae6b2 Binary files /dev/null and b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSunlight.testSunlightInputs_f3.png differ diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSunlight.testSunlightInputs_f3_angle.png b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSunlight.testSunlightInputs_f3_angle.png new file mode 100644 index 0000000000..9868eae6b2 Binary files /dev/null and b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSunlight.testSunlightInputs_f3_angle.png differ diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSunlight.testSunlightParallax_f3.png b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSunlight.testSunlightParallax_f3.png new file mode 100644 index 0000000000..85b79db2c9 Binary files /dev/null and b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSunlight.testSunlightParallax_f3.png differ diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSunlight.testSunlightParallax_f3_angle.png b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSunlight.testSunlightParallax_f3_angle.png new file mode 100644 index 0000000000..85b79db2c9 Binary files /dev/null and b/jme3-screenshot-tests/jme3-screenshot-tests-desktop/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSunlight.testSunlightParallax_f3_angle.png differ diff --git a/jme3-screenshot-tests/jme3-screenshot-tests-shared/src/main/java/org/jmonkeyengine/screenshottests/scenarios/light/pbr/ScenarioPBRSunlight.java b/jme3-screenshot-tests/jme3-screenshot-tests-shared/src/main/java/org/jmonkeyengine/screenshottests/scenarios/light/pbr/ScenarioPBRSunlight.java new file mode 100644 index 0000000000..1700993aed --- /dev/null +++ b/jme3-screenshot-tests/jme3-screenshot-tests-shared/src/main/java/org/jmonkeyengine/screenshottests/scenarios/light/pbr/ScenarioPBRSunlight.java @@ -0,0 +1,250 @@ +/* + * 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.light.pbr; + +import static org.jmonkeyengine.screenshottests.testframework.ScreenshotTestBase.screenshotTest; + +import com.jme3.app.Application; +import com.jme3.app.SimpleApplication; +import com.jme3.app.state.BaseAppState; +import com.jme3.asset.AssetManager; +import com.jme3.light.DirectionalLight; +import com.jme3.material.Material; +import com.jme3.math.ColorRGBA; +import com.jme3.math.FastMath; +import com.jme3.math.Vector3f; +import com.jme3.renderer.Camera; +import com.jme3.scene.Geometry; +import com.jme3.scene.Node; +import com.jme3.scene.VertexBuffer; +import com.jme3.scene.shape.Quad; +import com.jme3.texture.Image; +import com.jme3.texture.Texture; +import com.jme3.texture.Texture2D; +import com.jme3.texture.image.ColorSpace; +import com.jme3.util.BufferUtils; +import java.nio.ByteBuffer; +import org.jmonkeyengine.screenshottests.testframework.ScreenshotTest; + +/** + * Deterministic sunlight-exposure charts using the production PBR material. + * Each chart has exposure debug output (mode 6) above actual sunlit PBR output. + * No external assets, probes, shadows, post-processing or asynchronous baking + * are needed, so the same scene can run on desktop OpenGL, ANGLE and Android. + */ +public final class ScenarioPBRSunlight { + + private enum Input { + DEFAULT, ZERO, STATIC, VERTEX, MAP, COMBINED + } + + private ScenarioPBRSunlight() { + } + + /** + * Columns, left to right: no exposure parameters, static zero, static 0.5, + * vertex red, map red, and all three inputs multiplied together. + * + *

The vertex red channel rises from 0.25 to 0.75, while the map's four + * equal-width bands contain 0, 64/255, 128/255 and 1. The combined column is + * {@code 0.5 * (0.25 + 0.5 * u) * mapRed(u)}. Other vertex/texture channels + * deliberately differ from red. Vertex colours must not tint the albedo. + * The first debug column must be white and the second black; their lit + * counterparts must be fully lit and black respectively.

+ * + * @return the screenshot test configuration + */ + public static ScreenshotTest testSunlightInputs() { + return screenshotTest(new SunlightChart(false)).setFramesToTakeScreenshotsOn(3); + } + + /** + * Columns, left to right: exposure map without parallax, with parallax and + * valid tangents, and with parallax but no tangent buffer. + * + *

All quads are tilted 45 degrees. A constant maximum-height map and a + * deliberately large nonzero height shift the middle column's exposure + * bands to the right. The outer debug columns must retain identical band + * widths. This detects both sampling the original UVs after parallax and + * attempting a parallax offset without a valid tangent basis.

+ * + * @return the screenshot test configuration + */ + public static ScreenshotTest testSunlightParallax() { + return screenshotTest(new SunlightChart(true)).setFramesToTakeScreenshotsOn(3); + } + + private static final class SunlightChart extends BaseAppState { + private final boolean parallax; + private final Node chart = new Node("sunlight exposure chart"); + private final int columns; + + private SunlightChart(boolean parallax) { + this.parallax = parallax; + columns = parallax ? 3 : Input.values().length; + } + + @Override + protected void initialize(Application app) { + Camera camera = app.getCamera(); + camera.setParallelProjection(true); + camera.setLocation(new Vector3f(0, 0, 30)); + camera.lookAt(Vector3f.ZERO, Vector3f.UNIT_Y); + updateFrustum(camera); + + // Keep debug values linear, including the procedural exposure map. + app.getRenderer().setMainFrameBufferSrgb(false); + app.getRenderer().setLinearizeSrgbImages(false); + app.getViewPort().setBackgroundColor(new ColorRGBA(0.06f, 0.08f, 0.12f, 1)); + + DirectionalLight sunlight = new DirectionalLight(); + sunlight.setDirection(new Vector3f(0, 0, -1)); + sunlight.setColor(new ColorRGBA(1.5f, 1.5f, 1.5f, 1)); + chart.addLight(sunlight); + ((SimpleApplication) app).getRootNode().attachChild(chart); + + Texture2D exposureMap = redChannelMap(0, 64, 128, 255); + Texture2D heightMap = redChannelMap(255); + for (int row = 0; row < 2; row++) { + for (int column = 0; column < columns; column++) { + Geometry tile = createTile(app.getAssetManager(), row == 0); + tile.setLocalTranslation(2 * column - columns + 1, 1 - 2 * row, 0); + if (parallax) { + configureParallax(tile, column, exposureMap, heightMap); + } else { + configureInput(tile, Input.values()[column], exposureMap); + } + chart.attachChild(tile); + } + } + } + + @Override + public void update(float tpf) { + // Android surface resizes can otherwise replace the horizontal + // frustum. Preserve the complete chart at every capture resolution. + updateFrustum(getApplication().getCamera()); + } + + private void updateFrustum(Camera camera) { + camera.setFrustum(1, 100, -columns, columns, 2.4f, -2.4f); + } + + @Override + protected void cleanup(Application app) { + chart.removeFromParent(); + } + + @Override + protected void onEnable() { + } + + @Override + protected void onDisable() { + } + } + + private static Geometry createTile(AssetManager assets, boolean debug) { + Quad mesh = new Quad(1.6f, 1.5f); + // Centre the quad so that parallax columns have identical silhouettes. + mesh.setBuffer(VertexBuffer.Type.Position, 3, new float[]{ + -0.8f, -0.75f, 0, 0.8f, -0.75f, 0, + 0.8f, 0.75f, 0, -0.8f, 0.75f, 0 + }); + mesh.updateBound(); + // Every tile has these colours; only VERTEX and COMBINED enable them. + mesh.setBuffer(VertexBuffer.Type.Color, 4, new float[]{ + 0.25f, 1, 0, 1, 0.75f, 0, 1, 1, + 0.75f, 0, 1, 1, 0.25f, 1, 0, 1 + }); + + Material material = new Material(assets, "Common/MatDefs/Light/PBRLighting.j3md"); + material.setColor("BaseColor", new ColorRGBA(0.8f, 0.35f, 0.12f, 1)); + material.setFloat("Metallic", 0); + material.setFloat("Roughness", 0.8f); + material.setBoolean("UseSpecularAA", false); + if (debug) { + material.setInt("DebugValuesMode", 6); + } + Geometry tile = new Geometry(debug ? "exposure" : "sunlit PBR", mesh); + tile.setMaterial(material); + return tile; + } + + private static void configureInput(Geometry tile, Input input, Texture2D exposureMap) { + Material material = tile.getMaterial(); + if (input == Input.ZERO) { + material.setFloat("StaticSunIntensity", 0); + } else if (input == Input.STATIC || input == Input.COMBINED) { + material.setFloat("StaticSunIntensity", 0.5f); + } + if (input == Input.VERTEX || input == Input.COMBINED) { + material.setBoolean("UseVertexColorsAsSunIntensity", true); + } + if (input == Input.MAP || input == Input.COMBINED) { + material.setTexture("SunLightExposureMap", exposureMap); + } + } + + private static void configureParallax(Geometry tile, int column, + Texture2D exposureMap, Texture2D heightMap) { + tile.rotate(0, FastMath.QUARTER_PI, 0); + Material material = tile.getMaterial(); + material.setTexture("SunLightExposureMap", exposureMap); + if (column != 2) { + tile.getMesh().setBuffer(VertexBuffer.Type.Tangent, 4, new float[]{ + 1, 0, 0, 1, 1, 0, 0, 1, + 1, 0, 0, 1, 1, 0, 0, 1 + }); + } + if (column != 0) { + material.setTexture("ParallaxMap", heightMap); + material.setFloat("ParallaxHeight", 0.75f); + material.setBoolean("SteepParallax", false); + } + } + + private static Texture2D redChannelMap(int... redValues) { + ByteBuffer pixels = BufferUtils.createByteBuffer(redValues.length * 4); + for (int red : redValues) { + // Non-red channels are intentionally unsuitable as exposure data. + pixels.put((byte) red).put((byte) (255 - red)).put((byte) 32).put((byte) 255); + } + pixels.flip(); + Texture2D texture = new Texture2D(new Image(Image.Format.RGBA8, + redValues.length, 1, pixels, ColorSpace.Linear)); + texture.setMinFilter(Texture.MinFilter.NearestNoMipMaps); + texture.setMagFilter(Texture.MagFilter.Nearest); + texture.setWrap(Texture.WrapMode.EdgeClamp); + return texture; + } +}