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..8787b7e981 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; @@ -58,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; @@ -131,6 +135,29 @@ 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; + + /** + * 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); @@ -246,6 +273,69 @@ 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; + } + + /** + * 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. * @@ -297,6 +387,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); + if (updateBounds) { + // Update the mesh bounding volume to reflect the animated vertex positions. + geometry.updateModelBound(); + } } } @@ -306,6 +400,18 @@ private void controlRenderSoftware() { private void controlRenderHardware() { boneOffsetMatrices = armature.computeSkinningMatrices(); jointMatricesParam.setValue(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); + } + } + } } @Override @@ -751,6 +857,171 @@ private void applySkinningTangents(Mesh mesh, Matrix4f[] offsetMatrices, VertexB tb.updateData(ftb); } + /** + * 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 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(); + IndexBuffer boneIndex = IndexBuffer.wrapIndexBuffer(boneIndexVB.getData()); + FloatBuffer boneWeightBuf = (FloatBuffer) boneWeightVB.getData(); + // 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 numVerts = bindPos.limit() / 3; + + // 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(); + + 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; + } + + 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; + 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); + } + + /** + * 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. @@ -763,6 +1034,8 @@ 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); + oc.write(boundingUpdateBudget, "boundingUpdateBudget", Integer.MAX_VALUE); } /** @@ -777,6 +1050,9 @@ 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); + 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")) { @@ -794,6 +1070,7 @@ public void read(JmeImporter im) throws IOException { */ private void updateAnimationTargets(Spatial spatial) { targets.clear(); + boundsUpdateStates.clear(); collectAnimatedGeometries(spatial); } 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..d5ddb947ed 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,49 @@ 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); + // Enable per-frame bounds update (off by default). + sControl.setUpdateBounds(true); + + // 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)); + } } 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)")); + } +}