From 790500cce094ecb4583aa56adcb4b1cc04c78f57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 09:52:48 +0000 Subject: [PATCH 1/6] Initial plan From d20beb7c50d963c40d5dbdc8862cd31ca761bb5d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 10:10:31 +0000 Subject: [PATCH 2/6] Fix: Animated models should have proper model bound (issue #343) Agent-Logs-Url: https://github.com/jMonkeyEngine/jmonkeyengine/sessions/47f6b3d5-00bd-4253-a800-382c8b76d5ad Co-authored-by: riccardobl <4943530+riccardobl@users.noreply.github.com> --- .../java/com/jme3/anim/SkinningControl.java | 116 ++++++++++++++++++ .../com/jme3/animation/SkeletonControl.java | 116 ++++++++++++++++++ .../test/PreventCoreIssueRegressions.java | 44 +++++++ 3 files changed, 276 insertions(+) diff --git a/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java b/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java index 9ceb4b3638..a32937bd77 100644 --- a/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java +++ b/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java @@ -31,6 +31,8 @@ */ package com.jme3.anim; +import com.jme3.bounding.BoundingBox; +import com.jme3.bounding.BoundingVolume; import com.jme3.export.InputCapsule; import com.jme3.export.JmeExporter; import com.jme3.export.JmeImporter; @@ -297,6 +299,8 @@ private void controlRenderSoftware() { // NOTE: This assumes code higher up has already ensured this mesh is animated. // Otherwise, a crash will happen in skin update. applySoftwareSkinning(mesh, boneOffsetMatrices); + // Update the mesh bounding volume to reflect the animated vertex positions. + geometry.updateModelBound(); } } @@ -306,6 +310,16 @@ private void controlRenderSoftware() { private void controlRenderHardware() { boneOffsetMatrices = armature.computeSkinningMatrices(); jointMatricesParam.setValue(boneOffsetMatrices); + + // Hardware skinning transforms vertices on the GPU, so the CPU-side vertex + // buffer is not updated. Compute the animated bounding volume from the bind + // pose positions and the current skinning matrices so culling is correct. + for (Geometry geometry : targets) { + Mesh mesh = geometry.getMesh(); + if (mesh != null && mesh.isAnimated()) { + updateSkinnedMeshBound(geometry, mesh, boneOffsetMatrices); + } + } } @Override @@ -751,6 +765,108 @@ private void applySkinningTangents(Mesh mesh, Matrix4f[] offsetMatrices, VertexB tb.updateData(ftb); } + /** + * Computes the bounding volume of an animated mesh from the bind pose + * positions and the current skinning matrices, then sets it on the geometry. + * This is used during hardware skinning to keep culling correct, since the + * GPU-transformed vertex positions are not reflected in the CPU-side vertex + * buffer. + * + * @param geometry the geometry whose bound needs to be updated + * @param mesh the animated mesh + * @param offsetMatrices the bone offset matrices for this frame + */ + private static void updateSkinnedMeshBound(Geometry geometry, Mesh mesh, + Matrix4f[] offsetMatrices) { + VertexBuffer bindPosVB = mesh.getBuffer(Type.BindPosePosition); + if (bindPosVB == null) { + return; + } + VertexBuffer boneIndexVB = mesh.getBuffer(Type.BoneIndex); + VertexBuffer boneWeightVB = mesh.getBuffer(Type.BoneWeight); + if (boneIndexVB == null || boneWeightVB == null) { + return; + } + int maxWeightsPerVert = mesh.getMaxNumWeights(); + if (maxWeightsPerVert <= 0) { + return; + } + int fourMinusMaxWeights = 4 - maxWeightsPerVert; + + FloatBuffer bindPos = (FloatBuffer) bindPosVB.getData(); + bindPos.rewind(); + IndexBuffer boneIndex = IndexBuffer.wrapIndexBuffer(boneIndexVB.getData()); + FloatBuffer boneWeightBuf = (FloatBuffer) boneWeightVB.getData(); + boneWeightBuf.rewind(); + // Use array() when available (heap buffer), otherwise copy to a local array. + float[] weights; + if (boneWeightBuf.hasArray()) { + weights = boneWeightBuf.array(); + } else { + weights = new float[boneWeightBuf.limit()]; + boneWeightBuf.get(weights); + } + int idxWeights = 0; + + int numVerts = bindPos.limit() / 3; + float minX = Float.POSITIVE_INFINITY, minY = Float.POSITIVE_INFINITY, + minZ = Float.POSITIVE_INFINITY; + float maxX = Float.NEGATIVE_INFINITY, maxY = Float.NEGATIVE_INFINITY, + maxZ = Float.NEGATIVE_INFINITY; + + for (int v = 0; v < numVerts; v++) { + float vtx = bindPos.get(); + float vty = bindPos.get(); + float vtz = bindPos.get(); + + float rx, ry, rz; + if (weights[idxWeights] == 0) { + idxWeights += 4; + rx = vtx; + ry = vty; + rz = vtz; + } else { + rx = 0; + ry = 0; + rz = 0; + for (int w = 0; w < maxWeightsPerVert; w++) { + float weight = weights[idxWeights]; + Matrix4f mat = offsetMatrices[boneIndex.get(idxWeights++)]; + rx += (mat.m00 * vtx + mat.m01 * vty + mat.m02 * vtz + mat.m03) * weight; + ry += (mat.m10 * vtx + mat.m11 * vty + mat.m12 * vtz + mat.m13) * weight; + rz += (mat.m20 * vtx + mat.m21 * vty + mat.m22 * vtz + mat.m23) * weight; + } + idxWeights += fourMinusMaxWeights; + } + + if (rx < minX) minX = rx; + if (rx > maxX) maxX = rx; + if (ry < minY) minY = ry; + if (ry > maxY) maxY = ry; + if (rz < minZ) minZ = rz; + if (rz > maxZ) maxZ = rz; + } + + // Reuse the existing BoundingBox if possible to avoid allocation. + BoundingVolume bv = mesh.getBound(); + BoundingBox bbox; + if (bv instanceof BoundingBox) { + bbox = (BoundingBox) bv; + } else { + bbox = new BoundingBox(); + } + TempVars vars = TempVars.get(); + try { + vars.vect1.set(minX, minY, minZ); + vars.vect2.set(maxX, maxY, maxZ); + bbox.setMinMax(vars.vect1, vars.vect2); + } finally { + vars.release(); + } + // setModelBound() updates the mesh bound and triggers a world-bound refresh. + geometry.setModelBound(bbox); + } + /** * Serialize this Control to the specified exporter, for example when saving * to a J3O file. diff --git a/jme3-core/src/main/java/com/jme3/animation/SkeletonControl.java b/jme3-core/src/main/java/com/jme3/animation/SkeletonControl.java index 34d712f707..a884000947 100644 --- a/jme3-core/src/main/java/com/jme3/animation/SkeletonControl.java +++ b/jme3-core/src/main/java/com/jme3/animation/SkeletonControl.java @@ -32,6 +32,8 @@ package com.jme3.animation; import com.jme3.anim.SkinningControl; +import com.jme3.bounding.BoundingBox; +import com.jme3.bounding.BoundingVolume; import com.jme3.export.*; import com.jme3.material.MatParamOverride; import com.jme3.math.FastMath; @@ -259,12 +261,24 @@ private void controlRenderSoftware() { // already ensured this mesh is animated. // Otherwise a crash will happen in skin update. softwareSkinUpdate(mesh, offsetMatrices); + // Update the mesh bounding volume to reflect the animated vertex positions. + geometry.updateModelBound(); } } private void controlRenderHardware() { offsetMatrices = skeleton.computeSkinningMatrices(); boneMatricesParam.setValue(offsetMatrices); + + // Hardware skinning transforms vertices on the GPU, so the CPU-side vertex + // buffer is not updated. Compute the animated bounding volume from the bind + // pose positions and the current skinning matrices so culling is correct. + for (Geometry geometry : targets) { + Mesh mesh = geometry.getMesh(); + if (mesh != null && mesh.isAnimated()) { + updateSkinnedMeshBound(geometry, mesh, offsetMatrices); + } + } } @Override @@ -703,6 +717,108 @@ private void applySkinningTangents(Mesh mesh, Matrix4f[] offsetMatrices, VertexB tb.updateData(ftb); } + /** + * Computes the bounding volume of an animated mesh from the bind pose + * positions and the current skinning matrices, then sets it on the geometry. + * This is used during hardware skinning to keep culling correct, since the + * GPU-transformed vertex positions are not reflected in the CPU-side vertex + * buffer. + * + * @param geometry the geometry whose bound needs to be updated + * @param mesh the animated mesh + * @param offsetMatrices the bone offset matrices for this frame + */ + private static void updateSkinnedMeshBound(Geometry geometry, Mesh mesh, + Matrix4f[] offsetMatrices) { + VertexBuffer bindPosVB = mesh.getBuffer(Type.BindPosePosition); + if (bindPosVB == null) { + return; + } + VertexBuffer boneIndexVB = mesh.getBuffer(Type.BoneIndex); + VertexBuffer boneWeightVB = mesh.getBuffer(Type.BoneWeight); + if (boneIndexVB == null || boneWeightVB == null) { + return; + } + int maxWeightsPerVert = mesh.getMaxNumWeights(); + if (maxWeightsPerVert <= 0) { + return; + } + int fourMinusMaxWeights = 4 - maxWeightsPerVert; + + FloatBuffer bindPos = (FloatBuffer) bindPosVB.getData(); + bindPos.rewind(); + IndexBuffer boneIndex = IndexBuffer.wrapIndexBuffer(boneIndexVB.getData()); + FloatBuffer boneWeightBuf = (FloatBuffer) boneWeightVB.getData(); + boneWeightBuf.rewind(); + // Use array() when available (heap buffer), otherwise copy to a local array. + float[] weights; + if (boneWeightBuf.hasArray()) { + weights = boneWeightBuf.array(); + } else { + weights = new float[boneWeightBuf.limit()]; + boneWeightBuf.get(weights); + } + int idxWeights = 0; + + int numVerts = bindPos.limit() / 3; + float minX = Float.POSITIVE_INFINITY, minY = Float.POSITIVE_INFINITY, + minZ = Float.POSITIVE_INFINITY; + float maxX = Float.NEGATIVE_INFINITY, maxY = Float.NEGATIVE_INFINITY, + maxZ = Float.NEGATIVE_INFINITY; + + for (int v = 0; v < numVerts; v++) { + float vtx = bindPos.get(); + float vty = bindPos.get(); + float vtz = bindPos.get(); + + float rx, ry, rz; + if (weights[idxWeights] == 0) { + idxWeights += 4; + rx = vtx; + ry = vty; + rz = vtz; + } else { + rx = 0; + ry = 0; + rz = 0; + for (int w = 0; w < maxWeightsPerVert; w++) { + float weight = weights[idxWeights]; + Matrix4f mat = offsetMatrices[boneIndex.get(idxWeights++)]; + rx += (mat.m00 * vtx + mat.m01 * vty + mat.m02 * vtz + mat.m03) * weight; + ry += (mat.m10 * vtx + mat.m11 * vty + mat.m12 * vtz + mat.m13) * weight; + rz += (mat.m20 * vtx + mat.m21 * vty + mat.m22 * vtz + mat.m23) * weight; + } + idxWeights += fourMinusMaxWeights; + } + + if (rx < minX) minX = rx; + if (rx > maxX) maxX = rx; + if (ry < minY) minY = ry; + if (ry > maxY) maxY = ry; + if (rz < minZ) minZ = rz; + if (rz > maxZ) maxZ = rz; + } + + // Reuse the existing BoundingBox if possible to avoid allocation. + BoundingVolume bv = mesh.getBound(); + BoundingBox bbox; + if (bv instanceof BoundingBox) { + bbox = (BoundingBox) bv; + } else { + bbox = new BoundingBox(); + } + TempVars vars = TempVars.get(); + try { + vars.vect1.set(minX, minY, minZ); + vars.vect2.set(maxX, maxY, maxZ); + bbox.setMinMax(vars.vect1, vars.vect2); + } finally { + vars.release(); + } + // setModelBound() updates the mesh bound and triggers a world-bound refresh. + geometry.setModelBound(bbox); + } + @Override public void write(JmeExporter ex) throws IOException { super.write(ex); diff --git a/jme3-core/src/test/java/com/jme3/test/PreventCoreIssueRegressions.java b/jme3-core/src/test/java/com/jme3/test/PreventCoreIssueRegressions.java index d754c40ed3..91bbbd77c8 100644 --- a/jme3-core/src/test/java/com/jme3/test/PreventCoreIssueRegressions.java +++ b/jme3-core/src/test/java/com/jme3/test/PreventCoreIssueRegressions.java @@ -40,6 +40,7 @@ import com.jme3.app.state.ScreenshotAppState; import com.jme3.asset.AssetManager; import com.jme3.asset.DesktopAssetManager; +import com.jme3.bounding.BoundingVolume; import com.jme3.input.InputManager; import com.jme3.input.dummy.DummyKeyInput; import com.jme3.input.dummy.DummyMouseInput; @@ -130,4 +131,47 @@ public void testIssue1138() { Vector3f.isValidVector(joint.getLocalTranslation())); } } + + /** + * Test case for JME issue #343: Animated models should have proper model bound. + * + *

When software skinning is used, calling controlRender should update the + * bounding volumes of the animated geometries to reflect their current pose. + */ + @Test + public void testIssue343() { + AssetManager am = JmeSystem.newAssetManager( + PreventCoreIssueRegressions.class.getResource("/com/jme3/asset/Desktop.cfg")); + Node cgModel = (Node) am.loadModel("Models/Elephant/Elephant.mesh.xml"); + cgModel.scale(0.04f); + + AnimComposer composer = cgModel.getControl(AnimComposer.class); + SkinningControl sControl = cgModel.getControl(SkinningControl.class); + + // Force software skinning so bounds are computed from CPU vertex positions. + sControl.setHardwareSkinningPreferred(false); + + // Record the world bound in the bind pose. + cgModel.updateGeometricState(); + BoundingVolume bindPoseBound = cgModel.getWorldBound().clone(); + + // Advance the "legUp" animation, which raises a leg well beyond the bind pose. + composer.setCurrentAction("legUp"); + cgModel.updateLogicalState(0.5f); + + // Simulate the render pass: controlRender applies software skinning and + // calls geometry.updateModelBound() on each target. + RenderManager rm = new RenderManager(new NullRenderer()); + ViewPort vp = rm.createMainView("test", new Camera(1, 1)); + sControl.render(rm, vp); + + // Propagate the refreshed bounds up the scene graph. + cgModel.updateGeometricState(); + BoundingVolume animatedBound = cgModel.getWorldBound().clone(); + + // The bounding volume must differ from the bind pose bound. + Assert.assertFalse( + "Model bound should change after animation is applied via software skinning", + bindPoseBound.equals(animatedBound)); + } } From f4661dcffe5521dee1f5167ab06d104679f31d4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 10:22:22 +0000 Subject: [PATCH 3/6] Make bounds update toggleable (off by default) and add visual example Agent-Logs-Url: https://github.com/jMonkeyEngine/jmonkeyengine/sessions/1528c7f1-8585-400f-9b82-851054cf0408 Co-authored-by: riccardobl <4943530+riccardobl@users.noreply.github.com> --- .../java/com/jme3/anim/SkinningControl.java | 57 +++++- .../com/jme3/animation/SkeletonControl.java | 57 +++++- .../test/PreventCoreIssueRegressions.java | 2 + .../animation/TestAnimatedModelBound.java | 167 ++++++++++++++++++ 4 files changed, 265 insertions(+), 18 deletions(-) create mode 100644 jme3-examples/src/main/java/jme3test/animation/TestAnimatedModelBound.java diff --git a/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java b/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java index a32937bd77..3a8d49243c 100644 --- a/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java +++ b/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java @@ -133,6 +133,13 @@ public class SkinningControl extends AbstractControl implements JmeCloneable { */ private transient Matrix4f[] boneOffsetMatrices; + /** + * When true, the bounding volumes of animated geometries are updated each + * frame to match the current pose, ensuring correct frustum culling. + * Disabled by default because it adds CPU cost every frame. + */ + private boolean updateBounds = false; + private MatParamOverride numberOfJointsParam = new MatParamOverride(VarType.Int, "NumberOfBones", null); private MatParamOverride jointMatricesParam = new MatParamOverride(VarType.Matrix4Array, "BoneMatrices", null); @@ -248,6 +255,32 @@ public boolean isHardwareSkinningUsed() { return hwSkinningEnabled; } + /** + * Enables or disables per-frame bounding-volume updates for animated + * geometries. When enabled, the bounding volume of each deformed geometry + * is recomputed every render frame to match the current animation pose, + * ensuring correct frustum culling at the cost of additional CPU work. + * Disabled by default. + * + * @param updateBounds true to update bounds each frame, false to keep + * static bind-pose bounds (default=false) + * @see #isUpdateBounds() + */ + public void setUpdateBounds(boolean updateBounds) { + this.updateBounds = updateBounds; + } + + /** + * Returns whether per-frame bounding-volume updates are enabled for + * animated geometries. + * + * @return true if bounds are updated each frame, false otherwise + * @see #setUpdateBounds(boolean) + */ + public boolean isUpdateBounds() { + return updateBounds; + } + /** * Recursively finds and adds animated geometries to the targets list. * @@ -299,8 +332,10 @@ private void controlRenderSoftware() { // NOTE: This assumes code higher up has already ensured this mesh is animated. // Otherwise, a crash will happen in skin update. applySoftwareSkinning(mesh, boneOffsetMatrices); - // Update the mesh bounding volume to reflect the animated vertex positions. - geometry.updateModelBound(); + if (updateBounds) { + // Update the mesh bounding volume to reflect the animated vertex positions. + geometry.updateModelBound(); + } } } @@ -311,13 +346,15 @@ private void controlRenderHardware() { boneOffsetMatrices = armature.computeSkinningMatrices(); jointMatricesParam.setValue(boneOffsetMatrices); - // Hardware skinning transforms vertices on the GPU, so the CPU-side vertex - // buffer is not updated. Compute the animated bounding volume from the bind - // pose positions and the current skinning matrices so culling is correct. - for (Geometry geometry : targets) { - Mesh mesh = geometry.getMesh(); - if (mesh != null && mesh.isAnimated()) { - updateSkinnedMeshBound(geometry, mesh, boneOffsetMatrices); + if (updateBounds) { + // Hardware skinning transforms vertices on the GPU, so the CPU-side vertex + // buffer is not updated. Compute the animated bounding volume from the bind + // pose positions and the current skinning matrices so culling is correct. + for (Geometry geometry : targets) { + Mesh mesh = geometry.getMesh(); + if (mesh != null && mesh.isAnimated()) { + updateSkinnedMeshBound(geometry, mesh, boneOffsetMatrices); + } } } } @@ -879,6 +916,7 @@ public void write(JmeExporter ex) throws IOException { super.write(ex); OutputCapsule oc = ex.getCapsule(this); oc.write(armature, "armature", null); + oc.write(updateBounds, "updateBounds", false); } /** @@ -893,6 +931,7 @@ public void read(JmeImporter im) throws IOException { super.read(im); InputCapsule in = im.getCapsule(this); armature = (Armature) in.readSavable("armature", null); + updateBounds = in.readBoolean("updateBounds", false); for (MatParamOverride mpo : spatial.getLocalMatParamOverrides().getArray()) { if (mpo.getName().equals("NumberOfBones") || mpo.getName().equals("BoneMatrices")) { diff --git a/jme3-core/src/main/java/com/jme3/animation/SkeletonControl.java b/jme3-core/src/main/java/com/jme3/animation/SkeletonControl.java index a884000947..381ddbb6af 100644 --- a/jme3-core/src/main/java/com/jme3/animation/SkeletonControl.java +++ b/jme3-core/src/main/java/com/jme3/animation/SkeletonControl.java @@ -108,6 +108,13 @@ public class SkeletonControl extends AbstractControl implements Cloneable, JmeCl */ private transient Matrix4f[] offsetMatrices; + /** + * When true, the bounding volumes of animated geometries are updated each + * frame to match the current pose, ensuring correct frustum culling. + * Disabled by default because it adds CPU cost every frame. + */ + private boolean updateBounds = false; + private MatParamOverride numberOfBonesParam; private MatParamOverride boneMatricesParam; @@ -193,6 +200,32 @@ public boolean isHardwareSkinningUsed() { return hwSkinningEnabled; } + /** + * Enables or disables per-frame bounding-volume updates for animated + * geometries. When enabled, the bounding volume of each deformed geometry + * is recomputed every render frame to match the current animation pose, + * ensuring correct frustum culling at the cost of additional CPU work. + * Disabled by default. + * + * @param updateBounds true to update bounds each frame, false to keep + * static bind-pose bounds (default=false) + * @see #isUpdateBounds() + */ + public void setUpdateBounds(boolean updateBounds) { + this.updateBounds = updateBounds; + } + + /** + * Returns whether per-frame bounding-volume updates are enabled for + * animated geometries. + * + * @return true if bounds are updated each frame, false otherwise + * @see #setUpdateBounds(boolean) + */ + public boolean isUpdateBounds() { + return updateBounds; + } + /** * Creates a skeleton control. The list of targets will be acquired * automatically when the control is attached to a node. @@ -261,8 +294,10 @@ private void controlRenderSoftware() { // already ensured this mesh is animated. // Otherwise a crash will happen in skin update. softwareSkinUpdate(mesh, offsetMatrices); - // Update the mesh bounding volume to reflect the animated vertex positions. - geometry.updateModelBound(); + if (updateBounds) { + // Update the mesh bounding volume to reflect the animated vertex positions. + geometry.updateModelBound(); + } } } @@ -270,13 +305,15 @@ private void controlRenderHardware() { offsetMatrices = skeleton.computeSkinningMatrices(); boneMatricesParam.setValue(offsetMatrices); - // Hardware skinning transforms vertices on the GPU, so the CPU-side vertex - // buffer is not updated. Compute the animated bounding volume from the bind - // pose positions and the current skinning matrices so culling is correct. - for (Geometry geometry : targets) { - Mesh mesh = geometry.getMesh(); - if (mesh != null && mesh.isAnimated()) { - updateSkinnedMeshBound(geometry, mesh, offsetMatrices); + if (updateBounds) { + // Hardware skinning transforms vertices on the GPU, so the CPU-side vertex + // buffer is not updated. Compute the animated bounding volume from the bind + // pose positions and the current skinning matrices so culling is correct. + for (Geometry geometry : targets) { + Mesh mesh = geometry.getMesh(); + if (mesh != null && mesh.isAnimated()) { + updateSkinnedMeshBound(geometry, mesh, offsetMatrices); + } } } } @@ -827,6 +864,7 @@ public void write(JmeExporter ex) throws IOException { oc.write(numberOfBonesParam, "numberOfBonesParam", null); oc.write(boneMatricesParam, "boneMatricesParam", null); + oc.write(updateBounds, "updateBounds", false); } @Override @@ -837,6 +875,7 @@ public void read(JmeImporter im) throws IOException { numberOfBonesParam = (MatParamOverride) in.readSavable("numberOfBonesParam", null); boneMatricesParam = (MatParamOverride) in.readSavable("boneMatricesParam", null); + updateBounds = in.readBoolean("updateBounds", false); if (numberOfBonesParam == null) { numberOfBonesParam = new MatParamOverride(VarType.Int, "NumberOfBones", null); diff --git a/jme3-core/src/test/java/com/jme3/test/PreventCoreIssueRegressions.java b/jme3-core/src/test/java/com/jme3/test/PreventCoreIssueRegressions.java index 91bbbd77c8..d5ddb947ed 100644 --- a/jme3-core/src/test/java/com/jme3/test/PreventCoreIssueRegressions.java +++ b/jme3-core/src/test/java/com/jme3/test/PreventCoreIssueRegressions.java @@ -150,6 +150,8 @@ public void testIssue343() { // Force software skinning so bounds are computed from CPU vertex positions. sControl.setHardwareSkinningPreferred(false); + // Enable per-frame bounds update (off by default). + sControl.setUpdateBounds(true); // Record the world bound in the bind pose. cgModel.updateGeometricState(); diff --git a/jme3-examples/src/main/java/jme3test/animation/TestAnimatedModelBound.java b/jme3-examples/src/main/java/jme3test/animation/TestAnimatedModelBound.java new file mode 100644 index 0000000000..bc45c4c5b3 --- /dev/null +++ b/jme3-examples/src/main/java/jme3test/animation/TestAnimatedModelBound.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2009-2025 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 BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package jme3test.animation; + +import com.jme3.anim.AnimComposer; +import com.jme3.anim.SkinningControl; +import com.jme3.app.SimpleApplication; +import com.jme3.bounding.BoundingBox; +import com.jme3.bounding.BoundingVolume; +import com.jme3.font.BitmapText; +import com.jme3.input.KeyInput; +import com.jme3.input.controls.ActionListener; +import com.jme3.input.controls.KeyTrigger; +import com.jme3.light.AmbientLight; +import com.jme3.light.DirectionalLight; +import com.jme3.material.Material; +import com.jme3.math.ColorRGBA; +import com.jme3.math.Vector3f; +import com.jme3.scene.Geometry; +import com.jme3.scene.Node; +import com.jme3.scene.debug.WireBox; + +/** + * Demonstrates the toggleable per-frame bounding-volume update feature of + * {@link SkinningControl}. + * + *

The Elephant model plays its "legUp" animation in a loop. A wireframe + * box shows the world bounding volume of the model. Press {@code B} to + * toggle {@link SkinningControl#setUpdateBounds(boolean) updateBounds}: + * + *

+ * + * @see SkinningControl#setUpdateBounds(boolean) + */ +public class TestAnimatedModelBound extends SimpleApplication { + + /** Root node of the loaded model. */ + private Node modelRoot; + /** SkinningControl whose updateBounds flag we toggle. */ + private SkinningControl skinningControl; + /** Wireframe box visualizing the world bounding volume each frame. */ + private Geometry boundGeom; + /** Label shown in the top-left corner. */ + private BitmapText statusText; + + public static void main(String[] args) { + TestAnimatedModelBound app = new TestAnimatedModelBound(); + app.start(); + } + + @Override + public void simpleInitApp() { + // ---------- lighting ---------- + rootNode.addLight(new AmbientLight(new ColorRGBA(0.3f, 0.3f, 0.3f, 1f))); + DirectionalLight sun = new DirectionalLight( + new Vector3f(-1f, -1f, -1f).normalizeLocal(), + ColorRGBA.White); + rootNode.addLight(sun); + + // ---------- camera ---------- + cam.setLocation(new Vector3f(0f, 2f, 8f)); + cam.lookAt(Vector3f.ZERO, Vector3f.UNIT_Y); + flyCam.setMoveSpeed(5f); + + // ---------- model ---------- + modelRoot = (Node) assetManager.loadModel("Models/Elephant/Elephant.mesh.xml"); + float scale = 0.04f; + modelRoot.scale(scale); + rootNode.attachChild(modelRoot); + + skinningControl = modelRoot.getControl(SkinningControl.class); + // updateBounds is OFF by default — the bounding box will stay static. + skinningControl.setHardwareSkinningPreferred(false); // easier to visualize with SW skinning + + AnimComposer composer = modelRoot.getControl(AnimComposer.class); + composer.setCurrentAction("legUp"); + + // ---------- bounding-box visualizer ---------- + // Create an unshaded wireframe geometry; we reposition it every frame. + Material wireMat = new Material(assetManager, + "Common/MatDefs/Misc/Unshaded.j3md"); + wireMat.setColor("Color", ColorRGBA.Yellow); + wireMat.getAdditionalRenderState().setWireframe(true); + + boundGeom = new Geometry("boundingBox", new WireBox(1f, 1f, 1f)); + boundGeom.setMaterial(wireMat); + rootNode.attachChild(boundGeom); + + // ---------- HUD ---------- + guiFont = assetManager.loadFont("Interface/Fonts/Default.fnt"); + statusText = new BitmapText(guiFont); + statusText.setSize(guiFont.getCharSet().getRenderedSize()); + statusText.setLocalTranslation(10f, + settings.getHeight() - 10f, 0f); + guiNode.attachChild(statusText); + updateStatusText(); + + // ---------- key binding ---------- + inputManager.addMapping("ToggleBounds", + new KeyTrigger(KeyInput.KEY_B)); + inputManager.addListener(new ActionListener() { + @Override + public void onAction(String name, boolean isPressed, float tpf) { + if (isPressed) { + boolean current = skinningControl.isUpdateBounds(); + skinningControl.setUpdateBounds(!current); + updateStatusText(); + } + } + }, "ToggleBounds"); + } + + @Override + public void simpleUpdate(float tpf) { + // Update the wireframe box to match the current world bounding volume. + modelRoot.updateGeometricState(); + BoundingVolume wb = modelRoot.getWorldBound(); + if (wb instanceof BoundingBox) { + BoundingBox bbox = (BoundingBox) wb; + Vector3f center = bbox.getCenter(); + ((WireBox) boundGeom.getMesh()).updatePositions( + bbox.getXExtent(), bbox.getYExtent(), bbox.getZExtent()); + boundGeom.setLocalTranslation(center); + } + } + + /** Refreshes the HUD label that shows the current updateBounds state. */ + private void updateStatusText() { + boolean on = skinningControl.isUpdateBounds(); + statusText.setText( + "Press B to toggle updateBounds\n" + + "updateBounds: " + (on ? "ON — bounding box tracks the animated pose" + : "OFF — bounding box stays at bind pose (default)")); + } +} From 166a5d74ee92776ecc9c45a4e9a5235e7246274e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 10:25:03 +0000 Subject: [PATCH 4/6] Revert SkeletonControl changes - keep bounds update only in SkinningControl Agent-Logs-Url: https://github.com/jMonkeyEngine/jmonkeyengine/sessions/87b0376a-f5e4-4caf-bab1-6bb85e219ddf Co-authored-by: riccardobl <4943530+riccardobl@users.noreply.github.com> --- .../com/jme3/animation/SkeletonControl.java | 155 ------------------ 1 file changed, 155 deletions(-) diff --git a/jme3-core/src/main/java/com/jme3/animation/SkeletonControl.java b/jme3-core/src/main/java/com/jme3/animation/SkeletonControl.java index 381ddbb6af..34d712f707 100644 --- a/jme3-core/src/main/java/com/jme3/animation/SkeletonControl.java +++ b/jme3-core/src/main/java/com/jme3/animation/SkeletonControl.java @@ -32,8 +32,6 @@ package com.jme3.animation; import com.jme3.anim.SkinningControl; -import com.jme3.bounding.BoundingBox; -import com.jme3.bounding.BoundingVolume; import com.jme3.export.*; import com.jme3.material.MatParamOverride; import com.jme3.math.FastMath; @@ -108,13 +106,6 @@ public class SkeletonControl extends AbstractControl implements Cloneable, JmeCl */ private transient Matrix4f[] offsetMatrices; - /** - * When true, the bounding volumes of animated geometries are updated each - * frame to match the current pose, ensuring correct frustum culling. - * Disabled by default because it adds CPU cost every frame. - */ - private boolean updateBounds = false; - private MatParamOverride numberOfBonesParam; private MatParamOverride boneMatricesParam; @@ -200,32 +191,6 @@ public boolean isHardwareSkinningUsed() { return hwSkinningEnabled; } - /** - * Enables or disables per-frame bounding-volume updates for animated - * geometries. When enabled, the bounding volume of each deformed geometry - * is recomputed every render frame to match the current animation pose, - * ensuring correct frustum culling at the cost of additional CPU work. - * Disabled by default. - * - * @param updateBounds true to update bounds each frame, false to keep - * static bind-pose bounds (default=false) - * @see #isUpdateBounds() - */ - public void setUpdateBounds(boolean updateBounds) { - this.updateBounds = updateBounds; - } - - /** - * Returns whether per-frame bounding-volume updates are enabled for - * animated geometries. - * - * @return true if bounds are updated each frame, false otherwise - * @see #setUpdateBounds(boolean) - */ - public boolean isUpdateBounds() { - return updateBounds; - } - /** * Creates a skeleton control. The list of targets will be acquired * automatically when the control is attached to a node. @@ -294,28 +259,12 @@ private void controlRenderSoftware() { // already ensured this mesh is animated. // Otherwise a crash will happen in skin update. softwareSkinUpdate(mesh, offsetMatrices); - if (updateBounds) { - // Update the mesh bounding volume to reflect the animated vertex positions. - geometry.updateModelBound(); - } } } private void controlRenderHardware() { offsetMatrices = skeleton.computeSkinningMatrices(); boneMatricesParam.setValue(offsetMatrices); - - if (updateBounds) { - // Hardware skinning transforms vertices on the GPU, so the CPU-side vertex - // buffer is not updated. Compute the animated bounding volume from the bind - // pose positions and the current skinning matrices so culling is correct. - for (Geometry geometry : targets) { - Mesh mesh = geometry.getMesh(); - if (mesh != null && mesh.isAnimated()) { - updateSkinnedMeshBound(geometry, mesh, offsetMatrices); - } - } - } } @Override @@ -754,108 +703,6 @@ private void applySkinningTangents(Mesh mesh, Matrix4f[] offsetMatrices, VertexB tb.updateData(ftb); } - /** - * Computes the bounding volume of an animated mesh from the bind pose - * positions and the current skinning matrices, then sets it on the geometry. - * This is used during hardware skinning to keep culling correct, since the - * GPU-transformed vertex positions are not reflected in the CPU-side vertex - * buffer. - * - * @param geometry the geometry whose bound needs to be updated - * @param mesh the animated mesh - * @param offsetMatrices the bone offset matrices for this frame - */ - private static void updateSkinnedMeshBound(Geometry geometry, Mesh mesh, - Matrix4f[] offsetMatrices) { - VertexBuffer bindPosVB = mesh.getBuffer(Type.BindPosePosition); - if (bindPosVB == null) { - return; - } - VertexBuffer boneIndexVB = mesh.getBuffer(Type.BoneIndex); - VertexBuffer boneWeightVB = mesh.getBuffer(Type.BoneWeight); - if (boneIndexVB == null || boneWeightVB == null) { - return; - } - int maxWeightsPerVert = mesh.getMaxNumWeights(); - if (maxWeightsPerVert <= 0) { - return; - } - int fourMinusMaxWeights = 4 - maxWeightsPerVert; - - FloatBuffer bindPos = (FloatBuffer) bindPosVB.getData(); - bindPos.rewind(); - IndexBuffer boneIndex = IndexBuffer.wrapIndexBuffer(boneIndexVB.getData()); - FloatBuffer boneWeightBuf = (FloatBuffer) boneWeightVB.getData(); - boneWeightBuf.rewind(); - // Use array() when available (heap buffer), otherwise copy to a local array. - float[] weights; - if (boneWeightBuf.hasArray()) { - weights = boneWeightBuf.array(); - } else { - weights = new float[boneWeightBuf.limit()]; - boneWeightBuf.get(weights); - } - int idxWeights = 0; - - int numVerts = bindPos.limit() / 3; - float minX = Float.POSITIVE_INFINITY, minY = Float.POSITIVE_INFINITY, - minZ = Float.POSITIVE_INFINITY; - float maxX = Float.NEGATIVE_INFINITY, maxY = Float.NEGATIVE_INFINITY, - maxZ = Float.NEGATIVE_INFINITY; - - for (int v = 0; v < numVerts; v++) { - float vtx = bindPos.get(); - float vty = bindPos.get(); - float vtz = bindPos.get(); - - float rx, ry, rz; - if (weights[idxWeights] == 0) { - idxWeights += 4; - rx = vtx; - ry = vty; - rz = vtz; - } else { - rx = 0; - ry = 0; - rz = 0; - for (int w = 0; w < maxWeightsPerVert; w++) { - float weight = weights[idxWeights]; - Matrix4f mat = offsetMatrices[boneIndex.get(idxWeights++)]; - rx += (mat.m00 * vtx + mat.m01 * vty + mat.m02 * vtz + mat.m03) * weight; - ry += (mat.m10 * vtx + mat.m11 * vty + mat.m12 * vtz + mat.m13) * weight; - rz += (mat.m20 * vtx + mat.m21 * vty + mat.m22 * vtz + mat.m23) * weight; - } - idxWeights += fourMinusMaxWeights; - } - - if (rx < minX) minX = rx; - if (rx > maxX) maxX = rx; - if (ry < minY) minY = ry; - if (ry > maxY) maxY = ry; - if (rz < minZ) minZ = rz; - if (rz > maxZ) maxZ = rz; - } - - // Reuse the existing BoundingBox if possible to avoid allocation. - BoundingVolume bv = mesh.getBound(); - BoundingBox bbox; - if (bv instanceof BoundingBox) { - bbox = (BoundingBox) bv; - } else { - bbox = new BoundingBox(); - } - TempVars vars = TempVars.get(); - try { - vars.vect1.set(minX, minY, minZ); - vars.vect2.set(maxX, maxY, maxZ); - bbox.setMinMax(vars.vect1, vars.vect2); - } finally { - vars.release(); - } - // setModelBound() updates the mesh bound and triggers a world-bound refresh. - geometry.setModelBound(bbox); - } - @Override public void write(JmeExporter ex) throws IOException { super.write(ex); @@ -864,7 +711,6 @@ public void write(JmeExporter ex) throws IOException { oc.write(numberOfBonesParam, "numberOfBonesParam", null); oc.write(boneMatricesParam, "boneMatricesParam", null); - oc.write(updateBounds, "updateBounds", false); } @Override @@ -875,7 +721,6 @@ public void read(JmeImporter im) throws IOException { numberOfBonesParam = (MatParamOverride) in.readSavable("numberOfBonesParam", null); boneMatricesParam = (MatParamOverride) in.readSavable("boneMatricesParam", null); - updateBounds = in.readBoolean("updateBounds", false); if (numberOfBonesParam == null) { numberOfBonesParam = new MatParamOverride(VarType.Int, "NumberOfBones", null); From fab7f6070180791ab67de7156c44704f3ca012b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 10:47:42 +0000 Subject: [PATCH 5/6] Changes before error encountered Agent-Logs-Url: https://github.com/jMonkeyEngine/jmonkeyengine/sessions/0b0ee217-37c6-4d49-9616-3c58c9350a06 Co-authored-by: riccardobl <4943530+riccardobl@users.noreply.github.com> --- .../java/com/jme3/anim/SkinningControl.java | 149 ++++++++++++++++-- 1 file changed, 135 insertions(+), 14 deletions(-) diff --git a/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java b/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java index 3a8d49243c..c9905957fb 100644 --- a/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java +++ b/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java @@ -60,6 +60,8 @@ import java.io.IOException; import java.nio.Buffer; import java.nio.FloatBuffer; +import java.util.HashMap; +import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; @@ -140,6 +142,22 @@ public class SkinningControl extends AbstractControl implements JmeCloneable { */ private boolean updateBounds = false; + /** + * Maximum number of vertices processed per frame when updating bounding + * volumes during hardware skinning. Processing is spread across multiple + * frames (the cursor resumes where it left off), keeping each frame's cost + * bounded. {@link Integer#MAX_VALUE} (the default) means all vertices are + * processed in one frame, matching the old behaviour. + */ + private int boundingUpdateBudget = Integer.MAX_VALUE; + + /** + * Per-geometry state for incremental bounding-volume updates. Only used + * when {@link #boundingUpdateBudget} is smaller than the vertex count of + * the mesh, allowing the work to be spread over several frames. + */ + private transient Map boundsUpdateStates = new HashMap<>(); + private MatParamOverride numberOfJointsParam = new MatParamOverride(VarType.Int, "NumberOfBones", null); private MatParamOverride jointMatricesParam = new MatParamOverride(VarType.Matrix4Array, "BoneMatrices", null); @@ -281,6 +299,43 @@ public boolean isUpdateBounds() { return updateBounds; } + /** + * Sets the maximum number of vertices considered per frame when updating + * bounding volumes during hardware skinning. Use this to distribute the + * CPU cost of the bounds update across several frames instead of paying the + * full price in a single frame. + * + *

The update cursor advances by at most {@code budget} vertices each + * frame and resumes where it left off the next frame. When a full pass over + * all vertices is complete, the geometry's model bound is refreshed with + * the newly computed values. In the meantime, the geometry keeps the bound + * from the previous completed pass. + * + *

A value of {@link Integer#MAX_VALUE} (the default) processes all + * vertices in one frame, matching the original behaviour. Values ≤ 0 + * are treated as {@link Integer#MAX_VALUE}. + * + * @param budget max vertices per frame (any positive integer; values + * ≤ 0 are normalized to {@link Integer#MAX_VALUE}) + * @see #getBoundingUpdateBudget() + * @see #setUpdateBounds(boolean) + */ + public void setBoundingUpdateBudget(int budget) { + this.boundingUpdateBudget = (budget <= 0) ? Integer.MAX_VALUE : budget; + boundsUpdateStates.clear(); + } + + /** + * Returns the maximum number of vertices processed per frame when updating + * bounding volumes during hardware skinning. + * + * @return the bounding-update vertex budget + * @see #setBoundingUpdateBudget(int) + */ + public int getBoundingUpdateBudget() { + return boundingUpdateBudget; + } + /** * Recursively finds and adds animated geometries to the targets list. * @@ -803,17 +858,23 @@ private void applySkinningTangents(Mesh mesh, Matrix4f[] offsetMatrices, VertexB } /** - * Computes the bounding volume of an animated mesh from the bind pose - * positions and the current skinning matrices, then sets it on the geometry. - * This is used during hardware skinning to keep culling correct, since the - * GPU-transformed vertex positions are not reflected in the CPU-side vertex - * buffer. + * Computes (or incrementally advances) the bounding volume of an animated + * mesh from the bind-pose positions and the current skinning matrices, then + * sets it on the geometry once a full pass is complete. + * + *

When {@link #boundingUpdateBudget} is smaller than the vertex count, + * at most {@code boundingUpdateBudget} vertices are processed each call. + * The per-geometry {@link BoundsUpdateState} records the cursor and the + * in-progress min/max so subsequent calls resume from where they left off. + * The geometry's bound is only updated after all vertices have been visited + * in a single pass; in the meantime it keeps the bound from the last + * completed pass. * * @param geometry the geometry whose bound needs to be updated * @param mesh the animated mesh * @param offsetMatrices the bone offset matrices for this frame */ - private static void updateSkinnedMeshBound(Geometry geometry, Mesh mesh, + private void updateSkinnedMeshBound(Geometry geometry, Mesh mesh, Matrix4f[] offsetMatrices) { VertexBuffer bindPosVB = mesh.getBuffer(Type.BindPosePosition); if (bindPosVB == null) { @@ -831,27 +892,56 @@ private static void updateSkinnedMeshBound(Geometry geometry, Mesh mesh, int fourMinusMaxWeights = 4 - maxWeightsPerVert; FloatBuffer bindPos = (FloatBuffer) bindPosVB.getData(); - bindPos.rewind(); IndexBuffer boneIndex = IndexBuffer.wrapIndexBuffer(boneIndexVB.getData()); FloatBuffer boneWeightBuf = (FloatBuffer) boneWeightVB.getData(); - boneWeightBuf.rewind(); // Use array() when available (heap buffer), otherwise copy to a local array. float[] weights; if (boneWeightBuf.hasArray()) { weights = boneWeightBuf.array(); } else { weights = new float[boneWeightBuf.limit()]; + boneWeightBuf.rewind(); boneWeightBuf.get(weights); } - int idxWeights = 0; int numVerts = bindPos.limit() / 3; - float minX = Float.POSITIVE_INFINITY, minY = Float.POSITIVE_INFINITY, - minZ = Float.POSITIVE_INFINITY; - float maxX = Float.NEGATIVE_INFINITY, maxY = Float.NEGATIVE_INFINITY, - maxZ = Float.NEGATIVE_INFINITY; - for (int v = 0; v < numVerts; v++) { + // Decide whether we need incremental (multi-frame) processing. + boolean incremental = (boundingUpdateBudget < numVerts); + BoundsUpdateState state = null; + if (incremental) { + state = boundsUpdateStates.get(geometry); + if (state == null) { + state = new BoundsUpdateState(); + boundsUpdateStates.put(geometry, state); + } + } + + // Starting vertex and accumulated min/max for this pass. + int startVertex; + float minX, minY, minZ, maxX, maxY, maxZ; + if (state != null && state.nextVertex > 0) { + // Resume an in-progress pass. + startVertex = state.nextVertex; + minX = state.minX; minY = state.minY; minZ = state.minZ; + maxX = state.maxX; maxY = state.maxY; maxZ = state.maxZ; + } else { + // Start a fresh pass. + startVertex = 0; + minX = Float.POSITIVE_INFINITY; minY = Float.POSITIVE_INFINITY; + minZ = Float.POSITIVE_INFINITY; + maxX = Float.NEGATIVE_INFINITY; maxY = Float.NEGATIVE_INFINITY; + maxZ = Float.NEGATIVE_INFINITY; + } + + int budget = incremental ? boundingUpdateBudget : numVerts; + int endVertex = Math.min(startVertex + budget, numVerts); + + // Position the bind-pose buffer at the correct vertex. + bindPos.position(startVertex * 3); + int idxWeights = startVertex * 4; + + for (int v = startVertex; v < endVertex; v++) { float vtx = bindPos.get(); float vty = bindPos.get(); float vtz = bindPos.get(); @@ -884,6 +974,19 @@ private static void updateSkinnedMeshBound(Geometry geometry, Mesh mesh, if (rz > maxZ) maxZ = rz; } + if (endVertex < numVerts) { + // Pass not yet complete – save state and wait for the next frame. + state.nextVertex = endVertex; + state.minX = minX; state.minY = minY; state.minZ = minZ; + state.maxX = maxX; state.maxY = maxY; state.maxZ = maxZ; + return; + } + + // Full pass complete – reset cursor and commit the bounding box. + if (state != null) { + state.nextVertex = 0; + } + // Reuse the existing BoundingBox if possible to avoid allocation. BoundingVolume bv = mesh.getBound(); BoundingBox bbox; @@ -904,6 +1007,21 @@ private static void updateSkinnedMeshBound(Geometry geometry, Mesh mesh, geometry.setModelBound(bbox); } + /** + * Holds the incremental bounding-volume update state for a single geometry + * when {@link #boundingUpdateBudget} limits processing to fewer than all + * vertices per frame. + */ + private static final class BoundsUpdateState { + int nextVertex = 0; + float minX = Float.POSITIVE_INFINITY; + float minY = Float.POSITIVE_INFINITY; + float minZ = Float.POSITIVE_INFINITY; + float maxX = Float.NEGATIVE_INFINITY; + float maxY = Float.NEGATIVE_INFINITY; + float maxZ = Float.NEGATIVE_INFINITY; + } + /** * Serialize this Control to the specified exporter, for example when saving * to a J3O file. @@ -917,6 +1035,7 @@ public void write(JmeExporter ex) throws IOException { OutputCapsule oc = ex.getCapsule(this); oc.write(armature, "armature", null); oc.write(updateBounds, "updateBounds", false); + oc.write(boundingUpdateBudget, "boundingUpdateBudget", Integer.MAX_VALUE); } /** @@ -932,6 +1051,7 @@ public void read(JmeImporter im) throws IOException { InputCapsule in = im.getCapsule(this); armature = (Armature) in.readSavable("armature", null); updateBounds = in.readBoolean("updateBounds", false); + boundingUpdateBudget = in.readInt("boundingUpdateBudget", Integer.MAX_VALUE); for (MatParamOverride mpo : spatial.getLocalMatParamOverrides().getArray()) { if (mpo.getName().equals("NumberOfBones") || mpo.getName().equals("BoneMatrices")) { @@ -949,6 +1069,7 @@ public void read(JmeImporter im) throws IOException { */ private void updateAnimationTargets(Spatial spatial) { targets.clear(); + boundsUpdateStates.clear(); collectAnimatedGeometries(spatial); } From 18d1dd97f43c576664731c9b65abddb2728985a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:07:37 +0000 Subject: [PATCH 6/6] Add setBoundingUpdateBudget for incremental per-frame bounds update in SkinningControl Agent-Logs-Url: https://github.com/jMonkeyEngine/jmonkeyengine/sessions/3d4465a6-f250-44fe-b834-04690fbb77f2 Co-authored-by: riccardobl <4943530+riccardobl@users.noreply.github.com> --- jme3-core/src/main/java/com/jme3/anim/SkinningControl.java | 1 + 1 file changed, 1 insertion(+) diff --git a/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java b/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java index c9905957fb..8787b7e981 100644 --- a/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java +++ b/jme3-core/src/main/java/com/jme3/anim/SkinningControl.java @@ -1052,6 +1052,7 @@ public void read(JmeImporter im) throws IOException { armature = (Armature) in.readSavable("armature", null); updateBounds = in.readBoolean("updateBounds", false); boundingUpdateBudget = in.readInt("boundingUpdateBudget", Integer.MAX_VALUE); + boundsUpdateStates = new HashMap<>(); for (MatParamOverride mpo : spatial.getLocalMatParamOverrides().getArray()) { if (mpo.getName().equals("NumberOfBones") || mpo.getName().equals("BoneMatrices")) {