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}: + * + *
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")) {