diff --git a/jme3-core/src/main/java/com/jme3/scene/Node.java b/jme3-core/src/main/java/com/jme3/scene/Node.java index 7e7a4d82f2..ec277d9c0b 100644 --- a/jme3-core/src/main/java/com/jme3/scene/Node.java +++ b/jme3-core/src/main/java/com/jme3/scene/Node.java @@ -271,9 +271,7 @@ private void findGlobalLights(Spatial sp, LightList list) { List children = n.getChildren(); for (int i = 0; i < children.size(); i++) { Spatial child = children.get(i); - if ((child.refreshFlags & RF_GLOBAL_LIGHTS)!= 0) { - findGlobalLights(child, list); - } + findGlobalLights(child, list); } } } @@ -284,11 +282,13 @@ public void updateGeometricState() { // This branch has no geometric state that requires updates. return; } - if ((refreshFlags & RF_LIGHTLIST) != 0) { + boolean updateGlobalLights = (refreshFlags & RF_GLOBAL_LIGHTS) != 0; + if ((refreshFlags & RF_LIGHTLIST) != 0 || (updateGlobalLights && parent == null)) { + // Global collection replaces the root's previous list, even when + // the global refresh came from an already-dirty descendant. updateWorldLightList(); } - boolean updateGlobalLights = (refreshFlags & RF_GLOBAL_LIGHTS) != 0; if (updateGlobalLights){ // if root node, we collect the global lights if (getParent() == null){ @@ -487,6 +487,9 @@ public Spatial detachChildAt(int index) { assert SceneGraphThreadWarden.assertOnCorrectThread(this); Spatial child = children.remove(index); if (child != null) { + // Refresh while the old parent is still reachable, so global lights + // anywhere in the detached subtree also invalidate the old root. + child.setLightListRefresh(); child.setParent(null); logger.log(Level.FINE, "{0}: Child removed.", this); @@ -498,8 +501,6 @@ public Spatial detachChildAt(int index) { // XXX: Not necessary? Since child will have transform updated // when attached anyway. child.setTransformRefresh(); - // lights are also inherited from parent - child.setLightListRefresh(); child.setMatParamOverrideRefresh(); invalidateUpdateList(); @@ -832,11 +833,32 @@ public void read(JmeImporter importer) throws IOException { if (children != null) { for (Spatial child : children.getArray()) { child.parent = this; + invalidateReadTransform(child); + // Carry pending global-light discovery from loaded descendants toward the root. + refreshFlags |= child.refreshFlags & RF_GLOBAL_LIGHTS; } } super.read(importer); } + /** + * Invalidates transforms that a loaded control may have computed before its + * subtree acquired a parent. Lazy world-transform and bound getters update + * their dirty ancestor path first, so a still-transform-dirty branch needs + * no further traversal. + */ + private static void invalidateReadTransform(Spatial spatial) { + if ((spatial.refreshFlags & RF_TRANSFORM) != 0) { + return; + } + spatial.refreshFlags |= RF_TRANSFORM | RF_BOUND; + if (spatial instanceof Node) { + for (Spatial child : ((Node) spatial).children.getArray()) { + invalidateReadTransform(child); + } + } + } + @Override public void setModelBound(BoundingVolume modelBound) { assert SceneGraphThreadWarden.assertOnCorrectThread(this); diff --git a/jme3-core/src/main/java/com/jme3/scene/Spatial.java b/jme3-core/src/main/java/com/jme3/scene/Spatial.java index 066ad82765..5898ebb671 100644 --- a/jme3-core/src/main/java/com/jme3/scene/Spatial.java +++ b/jme3-core/src/main/java/com/jme3/scene/Spatial.java @@ -1718,6 +1718,14 @@ public void read(JmeImporter im) throws IOException { localLights = (LightList) ic.readSavable("lights", null); localLights.setOwner(this); + // Rebuild derived world state without invoking overridable refresh methods during loading. + refreshFlags |= RF_TRANSFORM | RF_BOUND | RF_LIGHTLIST | RF_MATPARAM_OVERRIDE; + for (Light light : localLights) { + if (light.isGlobal()) { + refreshFlags |= RF_GLOBAL_LIGHTS; + break; + } + } ArrayList localOverridesList = ic.readSavableArrayList("overrides", null); if (localOverridesList == null) { diff --git a/jme3-core/src/test/java/com/jme3/scene/GlobalLightLifecycleTest.java b/jme3-core/src/test/java/com/jme3/scene/GlobalLightLifecycleTest.java new file mode 100644 index 0000000000..ceaa632d40 --- /dev/null +++ b/jme3-core/src/test/java/com/jme3/scene/GlobalLightLifecycleTest.java @@ -0,0 +1,431 @@ +/* + * 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.scene; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.jme3.export.binary.BinaryExporter; +import com.jme3.export.binary.BinaryImporter; +import com.jme3.light.DefaultLightFilter; +import com.jme3.light.Light; +import com.jme3.light.LightList; +import com.jme3.light.PointLight; +import com.jme3.renderer.Camera; +import com.jme3.scene.shape.Box; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Verifies that global lights follow the complete scene tree across light-list + * and attachment changes, using the public scene update lifecycle. + */ +public class GlobalLightLifecycleTest { + + /** + * Tests incremental collection independently of branch depth and sibling order. + * + * @param depth the source depth + * @param order the sibling attachment order + */ + @ParameterizedTest(name = "depth {0}, order {1}") + @MethodSource("treeConfigurations") + public void incrementalGlobalAdditionRetainsCleanBranches(int depth, String order) { + Scene scene = new Scene(depth, order); + update(scene.root); + scene.first.addLight(scene.firstLight); + update(scene.root); + assertLights(scene.observer, scene.firstLight); + scene.second.addLight(scene.secondLight); + update(scene.root); + assertTreeLights(scene.root, scene.firstLight, scene.secondLight); + assertFilteredLights(scene.observer, scene.firstLight, scene.secondLight); + for (int frame = 0; frame < 3; frame++) { + update(scene.root); + assertTreeLights(scene.root, scene.firstLight, scene.secondLight); + } + } + + @Test + public void removingGlobalLightRetainsCleanSiblingAndClearsLastLight() { + Scene scene = new Scene(3, "ABT"); + scene.addBothLights(); + scene.second.removeLight(scene.secondLight); + update(scene.root); + assertTreeLights(scene.root, scene.firstLight); + scene.first.removeLight(scene.firstLight); + update(scene.root); + assertTreeLights(scene.root); + update(scene.root); + assertTreeLights(scene.root); + } + + @Test + public void localLightEditOnGlobalSourcePreservesOtherBranches() { + Scene scene = new Scene(3, "ABT"); + scene.addBothLights(); + PointLight local = new PointLight(); + scene.second.addLight(local); + update(scene.root); + assertLights(scene.root, scene.firstLight, scene.secondLight); + assertLights(scene.observer, scene.firstLight, scene.secondLight); + assertLights(scene.second, scene.firstLight, scene.secondLight, local); + scene.second.removeLight(local); + update(scene.root); + assertTreeLights(scene.root, scene.firstLight, scene.secondLight); + } + + @Test + public void localLightsStillFollowOnlyTheirAncestors() { + Scene scene = new Scene(3, "BAT"); + PointLight rootLocal = new PointLight(); + scene.first.addLight(scene.firstLight); + update(scene.root); + scene.root.addLight(rootLocal); + PointLight branchLocal = new PointLight(); + scene.second.addLight(branchLocal); + update(scene.root); + assertLights(scene.root, rootLocal, scene.firstLight); + assertLights(scene.first, rootLocal, scene.firstLight); + assertLights(scene.second, branchLocal, rootLocal, scene.firstLight); + assertLights(scene.observer, rootLocal, scene.firstLight); + } + + @Test + public void geometryCanSupplyGlobalLights() { + Scene scene = new Scene(3, "TBA"); + Geometry source = new Geometry("source", new Box(1, 1, 1)); + scene.first.attachChild(source); + source.addLight(scene.firstLight); + update(scene.root); + scene.second.addLight(scene.secondLight); + update(scene.root); + assertTreeLights(scene.root, scene.firstLight, scene.secondLight); + source.removeFromParent(); + update(scene.root); + assertTreeLights(scene.root, scene.secondLight); + } + + @Test + public void rootCanSupplyGlobalLights() { + Scene scene = new Scene(3, "TBA"); + scene.root.addLight(scene.firstLight); + update(scene.root); + scene.second.addLight(scene.secondLight); + update(scene.root); + assertTreeLights(scene.root, scene.firstLight, scene.secondLight); + scene.root.removeLight(scene.firstLight); + update(scene.root); + assertTreeLights(scene.root, scene.secondLight); + } + + @Test + public void detachingDirectSourceRefreshesBothTrees() { + Scene scene = new Scene(1, "ABT"); + scene.addBothLights(); + assertSame(scene.firstBranch, scene.root.detachChildAt(0)); + update(scene.root); + update(scene.firstBranch); + assertTreeLights(scene.root, scene.secondLight); + assertTreeLights(scene.firstBranch, scene.firstLight); + } + + @Test + public void detachingNestedSourceRefreshesBothTrees() { + Scene scene = new Scene(3, "ABT"); + scene.addBothLights(); + scene.root.detachChild(scene.firstBranch); + update(scene.root); + update(scene.firstBranch); + assertTreeLights(scene.root, scene.secondLight); + assertTreeLights(scene.firstBranch, scene.firstLight); + } + + @Test + public void reparentingWithinTreePreservesCleanGlobalLights() { + Scene scene = new Scene(3, "ABT"); + scene.addBothLights(); + scene.targetBranch.attachChild(scene.firstBranch); + update(scene.root); + assertTreeLights(scene.root, scene.firstLight, scene.secondLight); + } + + @Test + public void reparentingAcrossRootsRefreshesSourceAndDestination() { + Scene previous = new Scene(3, "ABT"); + Scene destination = new Scene(3, "TBA"); + previous.addBothLights(); + destination.second.addLight(destination.secondLight); + update(destination.root); + destination.targetBranch.attachChild(previous.firstBranch); + update(destination.root); + update(previous.root); + assertTreeLights(previous.root, previous.secondLight); + assertTreeLights(destination.root, previous.firstLight, destination.secondLight); + } + + @Test + public void attachingAlreadyDirtySubtreeReplacesPreviousCollection() { + Scene scene = new Scene(3, "TBA"); + scene.first.addLight(scene.firstLight); + update(scene.root); + Node subtree = new Node("subtree"); + Node nested = new Node("nested"); + nested.addLight(scene.secondLight); + subtree.attachChild(nested); + scene.second.attachChild(subtree); + update(scene.root); + assertTreeLights(scene.root, scene.firstLight, scene.secondLight); + assertFilteredLights(scene.observer, scene.firstLight, scene.secondLight); + } + + @Test + public void reparentingAlreadyDirtySubtreeReplacesPreviousCollection() { + Scene scene = new Scene(3, "TBA"); + scene.addBothLights(); + PointLight added = new PointLight(true); + scene.first.addLight(added); + scene.second.attachChild(scene.firstBranch); + update(scene.root); + assertTreeLights(scene.root, scene.firstLight, scene.secondLight, added); + } + + @Test + public void multipleChangesBeforeUpdateUseFinalMembership() { + Scene scene = new Scene(3, "TBA"); + scene.addBothLights(); + PointLight replacement = new PointLight(true); + scene.first.removeLight(scene.firstLight); + scene.second.removeLight(scene.secondLight); + scene.first.addLight(replacement); + scene.root.detachChild(scene.secondBranch); + scene.root.attachChild(scene.secondBranch); + update(scene.root); + assertTreeLights(scene.root, replacement); + } + + @Test + public void detachingAllChildrenClearsDescendantGlobalLights() { + Scene scene = new Scene(3, "ABT"); + scene.addBothLights(); + PointLight rootLight = new PointLight(true); + scene.root.addLight(rootLight); + update(scene.root); + scene.root.detachAllChildren(); + update(scene.root); + assertLights(scene.root, rootLight); + update(scene.firstBranch); + update(scene.secondBranch); + assertTreeLights(scene.firstBranch, scene.firstLight); + assertTreeLights(scene.secondBranch, scene.secondLight); + } + + @Test + public void unchangedUpdatesDoNotCollectGlobalLightsAgain() { + CountingNode root = new CountingNode(); + Node branch = new Node("branch"); + root.attachChild(branch); + PointLight light = new PointLight(true); + branch.addLight(light); + update(root); + int initialReads = root.localLightListReads; + assertTrue(initialReads > 0); + for (int frame = 0; frame < 5; frame++) { + update(root); + assertTreeLights(root, light); + } + assertEquals(initialReads, root.localLightListReads); + } + + @Test + public void cloneRebuildsGlobalsIndependently() { + Scene original = new Scene(3, "ABT"); + original.addBothLights(); + Node cloned = (Node) original.root.clone(); + verifyCopiedScene(cloned); + assertTreeLights(original.root, original.firstLight, original.secondLight); + } + + @Test + public void binaryRoundTripRetainsGlobalLifecycle() throws IOException { + Scene original = new Scene(3, "ABT"); + original.addBothLights(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + BinaryExporter.getInstance().save(original.root, output); + ByteArrayInputStream input = new ByteArrayInputStream(output.toByteArray()); + Node loaded = (Node) BinaryImporter.getInstance().load(input); + Node host = new Node("host"); + host.attachChild(loaded); + verifyCopiedScene(host); + } + + @Test + public void emptyRootAndLocalOnlyTreeRemainValid() { + Node root = new Node("root"); + update(root); + assertLights(root); + Geometry child = new Geometry("child", new Box(1, 1, 1)); + root.attachChild(child); + PointLight local = new PointLight(); + root.addLight(local); + update(root); + assertTreeLights(root, local); + child.removeFromParent(); + update(root); + update(child); + assertLights(root, local); + assertLights(child); + } + + private static Stream treeConfigurations() { + List configurations = new ArrayList<>(); + for (int depth : new int[]{1, 3}) { + for (String order : new String[]{"ABT", "ATB", "BAT", "BTA", "TAB", "TBA"}) { + configurations.add(Arguments.of(depth, order)); + } + } + return configurations.stream(); + } + + private static void verifyCopiedScene(Node root) { + Node first = (Node) root.getChild("first-source"); + Node second = (Node) root.getChild("second-source"); + Light firstLight = first.getLocalLightList().get(0); + Light secondLight = second.getLocalLightList().get(0); + assertTrue(firstLight.isGlobal()); + assertTrue(secondLight.isGlobal()); + update(root); + assertTreeLights(root, firstLight, secondLight); + first.removeLight(firstLight); + update(root); + assertTreeLights(root, secondLight); + } + + private static void update(Spatial root) { + root.updateLogicalState(1f / 60f); + root.updateGeometricState(); + } + + private static void assertFilteredLights(Geometry observer, Light... expected) { + DefaultLightFilter filter = new DefaultLightFilter(); + filter.setCamera(new Camera(64, 64)); + LightList output = new LightList(observer); + filter.filterLights(observer, output); + assertLights(output, expected); + } + + private static void assertTreeLights(Spatial spatial, Light... expected) { + assertLights(spatial, expected); + if (spatial instanceof Node) { + for (Spatial child : ((Node) spatial).getChildren()) { + assertTreeLights(child, expected); + } + } + } + + private static void assertLights(Spatial spatial, Light... expected) { + assertLights(spatial.getWorldLightList(), expected); + } + + private static void assertLights(LightList actual, Light... expected) { + assertEquals(expected.length, actual.size(), "Unexpected light-list size"); + for (Light light : expected) { + int count = 0; + for (Light entry : actual) { + if (entry == light) { + count++; + } + } + assertEquals(1, count, "Each expected light must occur exactly once"); + } + } + + private static class Scene { + final Node root = new Node("root"); + final Node firstBranch = new Node("first-branch"); + final Node secondBranch = new Node("second-branch"); + final Node targetBranch = new Node("target-branch"); + final Node first; + final Node second; + final Geometry observer = new Geometry("observer", new Box(1, 1, 1)); + final PointLight firstLight = new PointLight(true); + final PointLight secondLight = new PointLight(true); + + Scene(int depth, String order) { + first = addChain(firstBranch, "first", depth); + second = addChain(secondBranch, "second", depth); + addChain(targetBranch, "target", depth).attachChild(observer); + for (char branch : order.toCharArray()) { + root.attachChild(branch == 'A' ? firstBranch : branch == 'B' ? secondBranch : targetBranch); + } + // Zero radius is the public infinite-range mode, avoiding finite-radius filtering. + firstLight.setRadius(0); + secondLight.setRadius(0); + } + + void addBothLights() { + first.addLight(firstLight); + second.addLight(secondLight); + update(root); + assertTreeLights(root, firstLight, secondLight); + } + + private static Node addChain(Node top, String name, int depth) { + for (int index = 1; index < depth; index++) { + Node child = new Node(name + index); + top.attachChild(child); + top = child; + } + top.setName(name + "-source"); + return top; + } + } + + private static class CountingNode extends Node { + int localLightListReads; + + @Override + public LightList getLocalLightList() { + localLightListReads++; + return super.getLocalLightList(); + } + } +} diff --git a/jme3-core/src/test/java/com/jme3/scene/LoadedGlobalLightInitializationTest.java b/jme3-core/src/test/java/com/jme3/scene/LoadedGlobalLightInitializationTest.java new file mode 100644 index 0000000000..862b8cc75e --- /dev/null +++ b/jme3-core/src/test/java/com/jme3/scene/LoadedGlobalLightInitializationTest.java @@ -0,0 +1,432 @@ +/* + * 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.scene; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.jme3.export.JmeImporter; +import com.jme3.export.binary.BinaryExporter; +import com.jme3.light.DefaultLightFilter; +import com.jme3.light.Light; +import com.jme3.light.LightList; +import com.jme3.light.PointLight; +import com.jme3.renderer.Camera; +import com.jme3.renderer.RenderManager; +import com.jme3.renderer.ViewPort; +import com.jme3.scene.control.AbstractControl; +import com.jme3.scene.shape.Box; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** Tests initial light discovery in a deserialized, standalone Node root. */ +public class LoadedGlobalLightInitializationTest { + + /** + * Exercises root, node, and geometry owners at different depths and sibling orders. + * + * @param depth the depth of the source branch + * @param owner the global light owner + * @param observerFirst whether the observer precedes the source branch + */ + @ParameterizedTest(name = "depth {0}, owner {1}, observer first {2}") + @MethodSource("sceneConfigurations") + public void standaloneLoadedRootDiscoversGlobals(int depth, String owner, boolean observerFirst) { + Node source = scene(depth, owner, observerFirst); + update(source); + Node loaded = BinaryExporter.saveAndLoad(null, source); + assertNull(loaded.getParent()); + List globals = new ArrayList<>(); + gatherGlobals(loaded, globals); + assertEquals(1, globals.size(), "The global flag must survive serialization"); + for (int frame = 0; frame < 4; frame++) { + updateAndVerify(loaded); + } + } + + @Test + public void rootAndSeparateBranchesRestoreAllGlobalLights() { + Node root = scene(4, "node", true); + addLight(root, "root-global", true, true); + Geometry geometry = new Geometry("other-source", new Box(1, 1, 1)); + addLight(geometry, "geometry-global", true, true); + root.attachChild(geometry); + update(root); + Node loaded = BinaryExporter.saveAndLoad(null, root); + List globals = new ArrayList<>(); + gatherGlobals(loaded, globals); + assertEquals(3, globals.size()); + updateAndVerify(loaded); + } + + @Test + public void localOnlyLightsKeepAncestorInheritance() { + Node root = scene(3, "none", false); + addLight(root, "root-local", false, true); + addLight(root.getChild("branch"), "branch-local", false, true); + addLight(root.getChild("source-geometry"), "geometry-local", false, true); + Node loaded = BinaryExporter.saveAndLoad(null, root); + updateAndVerify(loaded); + assertEquals(1, loaded.getChild("observer").getWorldLightList().size()); + assertEquals(3, loaded.getChild("source-geometry").getWorldLightList().size()); + } + + @Test + public void rootLocalLightIsRetainedWithDescendantGlobalLight() { + Node root = scene(4, "geometry", false); + addLight(root, "root-local", false, true); + addLight(root.getChild("branch"), "branch-local", false, true); + updateAndVerify(BinaryExporter.saveAndLoad(null, root)); + } + + @Test + public void disabledGlobalRemainsInWorldListsButIsFiltered() { + Node root = scene(3, "node", true); + addLight(root.getChild("source-geometry"), "disabled-global", true, false); + Node loaded = BinaryExporter.saveAndLoad(null, root); + List globals = new ArrayList<>(); + gatherGlobals(loaded, globals); + assertEquals(2, globals.size()); + assertFalse(globals.get(1).isEnabled()); + updateAndVerify(loaded); + assertEquals(2, loaded.getChild("observer").getWorldLightList().size()); + } + + @Test + public void emptyRootAndEmptyBranchesRemainEmpty() { + updateAndVerify(BinaryExporter.saveAndLoad(null, new Node("empty"))); + updateAndVerify(BinaryExporter.saveAndLoad(null, scene(8, "none", true))); + } + + @Test + public void attachingLoadedTreeBeforeFirstUpdateInitializesHost() { + final Node loaded = BinaryExporter.saveAndLoad(null, scene(3, "node", true)); + Node host = new Node("host"); + host.attachChild(new Geometry("host-observer", new Box(1, 1, 1))); + addLight(host, "host-global", true, true); + update(host); + host.attachChild(loaded); + updateAndVerify(host); + } + + @Test + public void loadedTreeCanMoveBeforeItsFirstUpdate() { + Node loaded = BinaryExporter.saveAndLoad(null, scene(3, "geometry", true)); + Node firstHost = new Node("first-host"); + Node secondHost = new Node("second-host"); + firstHost.attachChild(loaded); + secondHost.attachChild(loaded); + updateAndVerify(firstHost); + updateAndVerify(secondHost); + } + + @Test + public void cloneOfLoadedTreeInitializesIndependently() { + Node loaded = BinaryExporter.saveAndLoad(null, scene(3, "node", false)); + Node cloned = (Node) loaded.clone(); + updateAndVerify(cloned); + updateAndVerify(loaded); + } + + @Test + public void secondRoundTripAlsoInitializesStandaloneRoot() { + Node first = BinaryExporter.saveAndLoad(null, scene(3, "geometry", true)); + updateAndVerify(first); + Node second = BinaryExporter.saveAndLoad(null, first); + updateAndVerify(second); + updateAndVerify(first); + } + + @Test + public void readingDoesNotInvokeSpatialRefreshOverrides() { + LoadGuardNode root = new LoadGuardNode("guard-root"); + LoadGuardNode child = new LoadGuardNode("guard-child"); + root.attachChild(child); + addLight(root, "root-global", true, true); + addLight(child, "child-global", true, true); + updateAndVerify(BinaryExporter.saveAndLoad(null, root)); + } + + @Test + public void controlsStillReadAfterChildrenAreLinked() { + Node root = scene(3, "node", true); + root.addControl(new ChildCheckingControl()); + Node loaded = BinaryExporter.saveAndLoad(null, root); + ChildCheckingControl control = loaded.getControl(ChildCheckingControl.class); + assertNotNull(control); + assertTrue(control.sawLinkedChildren); + updateAndVerify(loaded); + } + + /** + * Counts global checks during loading, before normal frame-time collection. + * + * @param depth the number of nested nodes + */ + @ParameterizedTest(name = "load depth {0}") + @MethodSource("loadDepths") + public void readingScansOnlyEachLocalLightList(int depth) { + Node root = new Node("count-root"); + Node current = root; + for (int i = 0; i < depth; i++) { + Node child = new Node("count-" + i); + current.attachChild(child); + CountingPointLight local = new CountingPointLight(); + CountingPointLight global = new CountingPointLight(true); + child.addLight(local); + child.addLight(global); + current = child; + } + CountingPointLight.globalChecks = 0; + Node loaded = BinaryExporter.saveAndLoad(null, root); + assertTrue(CountingPointLight.globalChecks <= 2 * depth, + "Loading must not rescan already-read descendants"); + updateAndVerify(loaded); + CountingPointLight.globalChecks = 0; + for (int frame = 0; frame < 4; frame++) { + update(loaded); + } + assertEquals(0, CountingPointLight.globalChecks, "Unchanged updates must not rescan lights"); + } + + private static Stream sceneConfigurations() { + List configurations = new ArrayList<>(); + for (int depth : new int[]{1, 4, 12}) { + for (String owner : new String[]{"root", "node", "geometry"}) { + configurations.add(Arguments.of(depth, owner, true)); + configurations.add(Arguments.of(depth, owner, false)); + } + } + return configurations.stream(); + } + + private static Stream loadDepths() { + return Stream.of(1, 16, 64); + } + + private static Node scene(int depth, String owner, boolean observerFirst) { + Node root = new Node("root"); + Node branch = new Node("branch"); + Node source = branch; + for (int i = 1; i < depth; i++) { + Node child = new Node("source-" + i); + source.attachChild(child); + source = child; + } + Geometry geometry = new Geometry("source-geometry", new Box(1, 1, 1)); + source.attachChild(geometry); + Geometry observer = new Geometry("observer", new Box(1, 1, 1)); + root.attachChild(observerFirst ? observer : branch); + root.attachChild(observerFirst ? branch : observer); + if (!"none".equals(owner)) { + Spatial lightOwner = "root".equals(owner) ? root : "node".equals(owner) ? source : geometry; + addLight(lightOwner, "global", true, true); + } + return root; + } + + private static void addLight(Spatial owner, String name, boolean global, boolean enabled) { + PointLight light = new PointLight(global); + light.setName(name); + light.setEnabled(enabled); + owner.addLight(light); + } + + private static void update(Node root) { + root.updateLogicalState(0.016f); + root.updateGeometricState(); + } + + private static void updateAndVerify(Node root) { + update(root); + List globals = new ArrayList<>(); + gatherGlobals(root, globals); + verifyTree(root, globals, Collections.emptyList()); + } + + private static void gatherGlobals(Spatial spatial, List globals) { + for (Light light : spatial.getLocalLightList()) { + if (light.isGlobal()) { + globals.add(light); + } + } + if (spatial instanceof Node) { + for (Spatial child : ((Node) spatial).getChildren()) { + gatherGlobals(child, globals); + } + } + } + + private static void verifyTree(Spatial spatial, List globals, List inherited) { + List locals = new ArrayList<>(inherited); + for (Light light : spatial.getLocalLightList()) { + if (!light.isGlobal()) { + locals.add(light); + } + } + List expected = new ArrayList<>(globals); + expected.addAll(locals); + assertLights(spatial.getWorldLightList(), expected, spatial.getName()); + if (spatial instanceof Node) { + for (Spatial child : ((Node) spatial).getChildren()) { + assertSame(spatial, child.getParent()); + verifyTree(child, globals, locals); + } + } else if (spatial instanceof Geometry) { + Geometry geometry = (Geometry) spatial; + DefaultLightFilter filter = new DefaultLightFilter(); + filter.setCamera(new Camera(64, 64)); + LightList filtered = new LightList(geometry); + filter.filterLights(geometry, filtered); + expected.removeIf(light -> !light.isEnabled()); + assertLights(filtered, expected, "filtered " + spatial.getName()); + } + } + + private static void assertLights(LightList actual, List expected, String context) { + assertEquals(expected.size(), actual.size(), context); + for (Light light : expected) { + int occurrences = 0; + for (Light candidate : actual) { + if (candidate == light) { + occurrences++; + } + } + assertEquals(1, occurrences, context + ": " + light.getName()); + } + } + + /** A serializable probe for overridable refresh calls during partial loading. */ + public static class LoadGuardNode extends Node { + private boolean initialized; + + public LoadGuardNode() { + } + + LoadGuardNode(String name) { + super(name); + initialized = true; + } + + @Override + protected boolean hasGlobalLights() { + assertTrue(initialized, "Do not query subclass state during superclass read"); + return super.hasGlobalLights(); + } + + @Override + protected void setLightListRefresh() { + assertTrue(initialized, "Do not invoke subclass refresh during superclass read"); + super.setLightListRefresh(); + } + + @Override + protected void setTransformRefresh() { + assertTrue(initialized, "Do not invoke subclass transform refresh during superclass read"); + super.setTransformRefresh(); + } + + @Override + protected void setBoundRefresh() { + assertTrue(initialized, "Do not invoke subclass bound refresh during superclass read"); + super.setBoundRefresh(); + } + + @Override + protected void setMatParamOverrideRefresh() { + assertTrue(initialized, "Do not invoke subclass override refresh during superclass read"); + super.setMatParamOverrideRefresh(); + } + + @Override + public void read(JmeImporter importer) throws IOException { + super.read(importer); + initialized = true; + } + } + + /** A serializable counter for local-list scanning during load. */ + public static class CountingPointLight extends PointLight { + static int globalChecks; + + public CountingPointLight() { + } + + CountingPointLight(boolean global) { + super(global); + } + + @Override + public boolean isGlobal() { + globalChecks++; + return super.isGlobal(); + } + } + + /** A control that validates Node's established children-before-controls ordering. */ + public static class ChildCheckingControl extends AbstractControl { + boolean sawLinkedChildren; + + public ChildCheckingControl() { + } + + @Override + public void read(JmeImporter importer) throws IOException { + super.read(importer); + Node node = (Node) spatial; + assertEquals(2, node.getQuantity()); + for (Spatial child : node.getChildren()) { + assertSame(node, child.getParent()); + } + sawLinkedChildren = true; + } + + @Override + protected void controlUpdate(float tpf) { + } + + @Override + protected void controlRender(RenderManager renderManager, ViewPort viewPort) { + } + } +} diff --git a/jme3-core/src/test/java/com/jme3/scene/LoadedSceneStateInitializationTest.java b/jme3-core/src/test/java/com/jme3/scene/LoadedSceneStateInitializationTest.java new file mode 100644 index 0000000000..83e8e57e50 --- /dev/null +++ b/jme3-core/src/test/java/com/jme3/scene/LoadedSceneStateInitializationTest.java @@ -0,0 +1,369 @@ +/* + * 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.scene; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.jme3.bounding.BoundingBox; +import com.jme3.export.JmeExporter; +import com.jme3.export.JmeImporter; +import com.jme3.export.binary.BinaryExporter; +import com.jme3.material.MatParamOverride; +import com.jme3.math.Quaternion; +import com.jme3.math.Vector3f; +import com.jme3.renderer.RenderManager; +import com.jme3.renderer.ViewPort; +import com.jme3.scene.control.AbstractControl; +import com.jme3.scene.shape.Box; +import com.jme3.shader.VarType; +import java.io.IOException; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** Tests restoration of derived transforms, bounds, and material overrides after loading. */ +public class LoadedSceneStateInitializationTest { + + /** + * Tests composition of local transforms throughout a loaded tree. + * + * @param depth the number of nested source nodes + */ + @ParameterizedTest(name = "depth {0}") + @MethodSource("depths") + public void loadedWorldTransformsAndBoundsMatchSavedScene(int depth) { + Node source = transformedScene(depth); + update(source); + Node loaded = BinaryExporter.saveAndLoad(null, source); + for (int frame = 0; frame < 3; frame++) { + update(loaded); + assertTransformsAndBounds(source, loaded); + } + } + + @Test + public void standaloneRootTranslationIsRecomputedFromRestoredLocalValues() { + Node source = new Node("root"); + Geometry child = new Geometry("child", new Box(1, 1, 1)); + source.attachChild(child); + source.setLocalTranslation(3, 4, 5); + child.setLocalTranslation(1, 0, 0); + update(source); + Node loaded = BinaryExporter.saveAndLoad(null, source); + Spatial loadedChild = loaded.getChild("child"); + assertEquals(new Vector3f(3, 4, 5), loaded.getLocalTranslation()); + assertEquals(new Vector3f(1, 0, 0), loadedChild.getLocalTranslation()); + update(loaded); + assertEquals(new Vector3f(3, 4, 5), loaded.getWorldTranslation()); + assertEquals(new Vector3f(4, 4, 5), loadedChild.getWorldTranslation()); + assertEquals(new Vector3f(4, 4, 5), loadedChild.getWorldBound().getCenter()); + } + + @Test + public void materialOverridesRemainLocalAndInheritOnlyAlongTheirBranch() { + Node source = transformedScene(3); + Node branch = (Node) source.getChild("branch"); + source.addMatParamOverride(new MatParamOverride(VarType.Boolean, "UseVertexColor", true)); + branch.addMatParamOverride(new MatParamOverride(VarType.Float, "AlphaDiscardThreshold", 0.5f)); + update(source); + Node loaded = BinaryExporter.saveAndLoad(null, source); + assertEquals(1, loaded.getLocalMatParamOverrides().size()); + assertEquals(1, loaded.getChild("branch").getLocalMatParamOverrides().size()); + update(loaded); + assertEquals(1, loaded.getWorldMatParamOverrides().size()); + assertEquals(1, loaded.getChild("observer").getWorldMatParamOverrides().size()); + assertEquals(2, loaded.getChild("source-geometry").getWorldMatParamOverrides().size()); + assertSame(loaded.getLocalMatParamOverrides().get(0), + loaded.getChild("observer").getWorldMatParamOverrides().get(0)); + assertSame(loaded.getChild("branch").getLocalMatParamOverrides().get(0), + loaded.getChild("source-geometry").getWorldMatParamOverrides().get(1)); + } + + @Test + public void attachingBeforeFirstUpdateComposesWithHostTransformAndOverrides() { + Node source = transformedScene(3); + source.addMatParamOverride(new MatParamOverride(VarType.Float, "AlphaDiscardThreshold", 0.5f)); + Node loaded = BinaryExporter.saveAndLoad(null, source); + Node expectedHost = new Node("host"); + Node actualHost = new Node("host"); + for (Node host : new Node[]{expectedHost, actualHost}) { + host.setLocalTranslation(9, 8, 7); + host.addMatParamOverride(new MatParamOverride(VarType.Boolean, "UseVertexColor", true)); + update(host); + } + expectedHost.attachChild(source); + actualHost.attachChild(loaded); + update(expectedHost); + update(actualHost); + assertTransformsAndBounds(expectedHost, actualHost); + assertEquals(2, loaded.getChild("source-geometry").getWorldMatParamOverrides().size()); + } + + @Test + public void loadedTreeCanBeClonedAndSerializedAgain() { + Node source = transformedScene(3); + source.addMatParamOverride(new MatParamOverride(VarType.Boolean, "UseVertexColor", true)); + update(source); + Node loaded = BinaryExporter.saveAndLoad(null, source); + Node cloned = (Node) loaded.clone(); + update(cloned); + assertTransformsAndBounds(source, cloned); + assertEquals(1, cloned.getChild("source-geometry").getWorldMatParamOverrides().size()); + update(loaded); + Node second = BinaryExporter.saveAndLoad(null, loaded); + update(second); + assertTransformsAndBounds(source, second); + assertEquals(1, second.getChild("source-geometry").getWorldMatParamOverrides().size()); + } + + @Test + public void controlReadsStillSeeLoadedChildrenAndLocalState() { + Node source = transformedScene(2); + source.addMatParamOverride(new MatParamOverride(VarType.Boolean, "UseVertexColor", true)); + source.addControl(new LocalStateCheckingControl()); + Node loaded = BinaryExporter.saveAndLoad(null, source); + assertTrue(loaded.getControl(LocalStateCheckingControl.class).sawLocalState); + update(loaded); + update(source); + assertTransformsAndBounds(source, loaded); + } + + /** + * Tests world getters before a loaded subtree has acquired its outer parent. + * + * @param owner the spatial whose control queries world state while reading + * @param target whether the control queries itself, a descendant, or both + */ + @ParameterizedTest(name = "control owner {0}, query {1}") + @MethodSource("getterConfigurations") + public void controlWorldGettersDoNotConsumeTransformsBeforeParentInitialization( + String owner, String target) { + TransformGuardNode root = new TransformGuardNode("guard-root"); + TransformGuardNode branch = new TransformGuardNode("guard-branch"); + TransformGuardGeometry leaf = new TransformGuardGeometry("guard-leaf"); + root.attachChild(branch); + branch.attachChild(leaf); + root.setLocalTranslation(3, 4, 5); + branch.setLocalTranslation(1, 2, 3); + leaf.setLocalTranslation(4, 5, 6); + Spatial controlOwner = "root".equals(owner) ? root : "branch".equals(owner) ? branch : leaf; + controlOwner.addControl(new WorldGetterControl(target)); + update(root); + Node loaded = BinaryExporter.saveAndLoad(null, root); + Spatial loadedOwner = "root".equals(owner) ? loaded : loaded.getChild("guard-" + owner); + assertTrue(loadedOwner.getControl(WorldGetterControl.class).queriedWorldTransforms); + update(loaded); + assertEquals(new Vector3f(8, 11, 14), loaded.getChild("guard-leaf").getWorldTranslation()); + assertTransformsAndBounds(root, loaded); + } + + private static Stream getterConfigurations() { + return Stream.of( + Arguments.of("root", "self"), + Arguments.of("root", "descendant"), + Arguments.of("root", "both"), + Arguments.of("branch", "self"), + Arguments.of("branch", "descendant"), + Arguments.of("branch", "both"), + Arguments.of("leaf", "self")); + } + + private static Stream depths() { + return Stream.of(1, 4, 12); + } + + private static Node transformedScene(int depth) { + Node root = new Node("root"); + root.setLocalTranslation(3, 4, 5); + root.setLocalScale(1.5f, 2, 0.5f); + root.setLocalRotation(new Quaternion().fromAngles(0.1f, 0.2f, 0.3f)); + Node current = root; + for (int i = 0; i < depth; i++) { + Node child = new Node(i == 0 ? "branch" : "nested-" + i); + child.setLocalTranslation(1, 2, 3); + child.setLocalScale(0.8f, 1.1f, 0.9f); + child.setLocalRotation(new Quaternion().fromAngles(0.2f, 0.1f, 0.05f)); + current.attachChild(child); + current = child; + } + current.attachChild(new Geometry("source-geometry", new Box(1, 2, 3))); + root.attachChild(new Geometry("observer", new Box(2, 1, 1))); + return root; + } + + private static void update(Node root) { + root.updateLogicalState(0.016f); + root.updateGeometricState(); + } + + private static void assertTransformsAndBounds(Spatial expected, Spatial actual) { + assertEquals(expected.getLocalTransform(), actual.getLocalTransform(), actual.getName()); + assertEquals(expected.getWorldTransform(), actual.getWorldTransform(), actual.getName()); + BoundingBox expectedBounds = (BoundingBox) expected.getWorldBound(); + BoundingBox actualBounds = (BoundingBox) actual.getWorldBound(); + assertEquals(expectedBounds.getCenter(), actualBounds.getCenter(), actual.getName()); + assertEquals(expectedBounds.getXExtent(), actualBounds.getXExtent(), actual.getName()); + assertEquals(expectedBounds.getYExtent(), actualBounds.getYExtent(), actual.getName()); + assertEquals(expectedBounds.getZExtent(), actualBounds.getZExtent(), actual.getName()); + if (expected instanceof Node) { + Node expectedNode = (Node) expected; + Node actualNode = (Node) actual; + assertEquals(expectedNode.getQuantity(), actualNode.getQuantity()); + for (int i = 0; i < expectedNode.getQuantity(); i++) { + assertTransformsAndBounds(expectedNode.getChild(i), actualNode.getChild(i)); + } + } + } + + /** A Node that detects virtual transform updates before its read has finished. */ + public static class TransformGuardNode extends Node { + private boolean initialized; + + public TransformGuardNode() { + } + + TransformGuardNode(String name) { + super(name); + initialized = true; + } + + @Override + protected void updateWorldTransforms() { + assertTrue(initialized, "Do not update a partially read Node subclass"); + super.updateWorldTransforms(); + } + + @Override + public void read(JmeImporter importer) throws IOException { + super.read(importer); + initialized = true; + } + } + + /** A Geometry that detects virtual transform updates before its read has finished. */ + public static class TransformGuardGeometry extends Geometry { + private boolean initialized; + + public TransformGuardGeometry() { + } + + TransformGuardGeometry(String name) { + super(name, new Box(1, 1, 1)); + initialized = true; + } + + @Override + protected void updateWorldTransforms() { + assertTrue(initialized, "Do not update a partially read Geometry subclass"); + super.updateWorldTransforms(); + } + + @Override + public void read(JmeImporter importer) throws IOException { + super.read(importer); + initialized = true; + } + } + + /** A control that queries world transforms while its owner is being read. */ + public static class WorldGetterControl extends AbstractControl { + boolean queriedWorldTransforms; + private String target; + + public WorldGetterControl() { + } + + WorldGetterControl(String target) { + this.target = target; + } + + @Override + public void write(JmeExporter exporter) throws IOException { + super.write(exporter); + exporter.getCapsule(this).write(target, "target", "self"); + } + + @Override + public void read(JmeImporter importer) throws IOException { + super.read(importer); + target = importer.getCapsule(this).readString("target", "self"); + if (!"descendant".equals(target)) { + spatial.getWorldTranslation(); + } + if (!"self".equals(target)) { + ((Node) spatial).getChild("guard-leaf").getWorldTranslation(); + } + queriedWorldTransforms = true; + } + + @Override + protected void controlUpdate(float tpf) { + } + + @Override + protected void controlRender(RenderManager renderManager, ViewPort viewPort) { + } + } + + /** A control that checks local data and child links at deserialization time. */ + public static class LocalStateCheckingControl extends AbstractControl { + boolean sawLocalState; + + public LocalStateCheckingControl() { + } + + @Override + public void read(JmeImporter importer) throws IOException { + super.read(importer); + Node node = (Node) spatial; + assertEquals(new Vector3f(3, 4, 5), node.getLocalTranslation()); + assertEquals(1, node.getLocalMatParamOverrides().size()); + assertEquals(2, node.getQuantity()); + for (Spatial child : node.getChildren()) { + assertSame(node, child.getParent()); + } + sawLocalState = true; + } + + @Override + protected void controlUpdate(float tpf) { + } + + @Override + protected void controlRender(RenderManager renderManager, ViewPort viewPort) { + } + } +}