Skip to content

Commit d20beb7

Browse files
Copilotriccardobl
andcommitted
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>
1 parent 790500c commit d20beb7

3 files changed

Lines changed: 276 additions & 0 deletions

File tree

jme3-core/src/main/java/com/jme3/anim/SkinningControl.java

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
*/
3232
package com.jme3.anim;
3333

34+
import com.jme3.bounding.BoundingBox;
35+
import com.jme3.bounding.BoundingVolume;
3436
import com.jme3.export.InputCapsule;
3537
import com.jme3.export.JmeExporter;
3638
import com.jme3.export.JmeImporter;
@@ -297,6 +299,8 @@ private void controlRenderSoftware() {
297299
// NOTE: This assumes code higher up has already ensured this mesh is animated.
298300
// Otherwise, a crash will happen in skin update.
299301
applySoftwareSkinning(mesh, boneOffsetMatrices);
302+
// Update the mesh bounding volume to reflect the animated vertex positions.
303+
geometry.updateModelBound();
300304
}
301305
}
302306

@@ -306,6 +310,16 @@ private void controlRenderSoftware() {
306310
private void controlRenderHardware() {
307311
boneOffsetMatrices = armature.computeSkinningMatrices();
308312
jointMatricesParam.setValue(boneOffsetMatrices);
313+
314+
// Hardware skinning transforms vertices on the GPU, so the CPU-side vertex
315+
// buffer is not updated. Compute the animated bounding volume from the bind
316+
// pose positions and the current skinning matrices so culling is correct.
317+
for (Geometry geometry : targets) {
318+
Mesh mesh = geometry.getMesh();
319+
if (mesh != null && mesh.isAnimated()) {
320+
updateSkinnedMeshBound(geometry, mesh, boneOffsetMatrices);
321+
}
322+
}
309323
}
310324

311325
@Override
@@ -751,6 +765,108 @@ private void applySkinningTangents(Mesh mesh, Matrix4f[] offsetMatrices, VertexB
751765
tb.updateData(ftb);
752766
}
753767

768+
/**
769+
* Computes the bounding volume of an animated mesh from the bind pose
770+
* positions and the current skinning matrices, then sets it on the geometry.
771+
* This is used during hardware skinning to keep culling correct, since the
772+
* GPU-transformed vertex positions are not reflected in the CPU-side vertex
773+
* buffer.
774+
*
775+
* @param geometry the geometry whose bound needs to be updated
776+
* @param mesh the animated mesh
777+
* @param offsetMatrices the bone offset matrices for this frame
778+
*/
779+
private static void updateSkinnedMeshBound(Geometry geometry, Mesh mesh,
780+
Matrix4f[] offsetMatrices) {
781+
VertexBuffer bindPosVB = mesh.getBuffer(Type.BindPosePosition);
782+
if (bindPosVB == null) {
783+
return;
784+
}
785+
VertexBuffer boneIndexVB = mesh.getBuffer(Type.BoneIndex);
786+
VertexBuffer boneWeightVB = mesh.getBuffer(Type.BoneWeight);
787+
if (boneIndexVB == null || boneWeightVB == null) {
788+
return;
789+
}
790+
int maxWeightsPerVert = mesh.getMaxNumWeights();
791+
if (maxWeightsPerVert <= 0) {
792+
return;
793+
}
794+
int fourMinusMaxWeights = 4 - maxWeightsPerVert;
795+
796+
FloatBuffer bindPos = (FloatBuffer) bindPosVB.getData();
797+
bindPos.rewind();
798+
IndexBuffer boneIndex = IndexBuffer.wrapIndexBuffer(boneIndexVB.getData());
799+
FloatBuffer boneWeightBuf = (FloatBuffer) boneWeightVB.getData();
800+
boneWeightBuf.rewind();
801+
// Use array() when available (heap buffer), otherwise copy to a local array.
802+
float[] weights;
803+
if (boneWeightBuf.hasArray()) {
804+
weights = boneWeightBuf.array();
805+
} else {
806+
weights = new float[boneWeightBuf.limit()];
807+
boneWeightBuf.get(weights);
808+
}
809+
int idxWeights = 0;
810+
811+
int numVerts = bindPos.limit() / 3;
812+
float minX = Float.POSITIVE_INFINITY, minY = Float.POSITIVE_INFINITY,
813+
minZ = Float.POSITIVE_INFINITY;
814+
float maxX = Float.NEGATIVE_INFINITY, maxY = Float.NEGATIVE_INFINITY,
815+
maxZ = Float.NEGATIVE_INFINITY;
816+
817+
for (int v = 0; v < numVerts; v++) {
818+
float vtx = bindPos.get();
819+
float vty = bindPos.get();
820+
float vtz = bindPos.get();
821+
822+
float rx, ry, rz;
823+
if (weights[idxWeights] == 0) {
824+
idxWeights += 4;
825+
rx = vtx;
826+
ry = vty;
827+
rz = vtz;
828+
} else {
829+
rx = 0;
830+
ry = 0;
831+
rz = 0;
832+
for (int w = 0; w < maxWeightsPerVert; w++) {
833+
float weight = weights[idxWeights];
834+
Matrix4f mat = offsetMatrices[boneIndex.get(idxWeights++)];
835+
rx += (mat.m00 * vtx + mat.m01 * vty + mat.m02 * vtz + mat.m03) * weight;
836+
ry += (mat.m10 * vtx + mat.m11 * vty + mat.m12 * vtz + mat.m13) * weight;
837+
rz += (mat.m20 * vtx + mat.m21 * vty + mat.m22 * vtz + mat.m23) * weight;
838+
}
839+
idxWeights += fourMinusMaxWeights;
840+
}
841+
842+
if (rx < minX) minX = rx;
843+
if (rx > maxX) maxX = rx;
844+
if (ry < minY) minY = ry;
845+
if (ry > maxY) maxY = ry;
846+
if (rz < minZ) minZ = rz;
847+
if (rz > maxZ) maxZ = rz;
848+
}
849+
850+
// Reuse the existing BoundingBox if possible to avoid allocation.
851+
BoundingVolume bv = mesh.getBound();
852+
BoundingBox bbox;
853+
if (bv instanceof BoundingBox) {
854+
bbox = (BoundingBox) bv;
855+
} else {
856+
bbox = new BoundingBox();
857+
}
858+
TempVars vars = TempVars.get();
859+
try {
860+
vars.vect1.set(minX, minY, minZ);
861+
vars.vect2.set(maxX, maxY, maxZ);
862+
bbox.setMinMax(vars.vect1, vars.vect2);
863+
} finally {
864+
vars.release();
865+
}
866+
// setModelBound() updates the mesh bound and triggers a world-bound refresh.
867+
geometry.setModelBound(bbox);
868+
}
869+
754870
/**
755871
* Serialize this Control to the specified exporter, for example when saving
756872
* to a J3O file.

jme3-core/src/main/java/com/jme3/animation/SkeletonControl.java

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
package com.jme3.animation;
3333

3434
import com.jme3.anim.SkinningControl;
35+
import com.jme3.bounding.BoundingBox;
36+
import com.jme3.bounding.BoundingVolume;
3537
import com.jme3.export.*;
3638
import com.jme3.material.MatParamOverride;
3739
import com.jme3.math.FastMath;
@@ -259,12 +261,24 @@ private void controlRenderSoftware() {
259261
// already ensured this mesh is animated.
260262
// Otherwise a crash will happen in skin update.
261263
softwareSkinUpdate(mesh, offsetMatrices);
264+
// Update the mesh bounding volume to reflect the animated vertex positions.
265+
geometry.updateModelBound();
262266
}
263267
}
264268

265269
private void controlRenderHardware() {
266270
offsetMatrices = skeleton.computeSkinningMatrices();
267271
boneMatricesParam.setValue(offsetMatrices);
272+
273+
// Hardware skinning transforms vertices on the GPU, so the CPU-side vertex
274+
// buffer is not updated. Compute the animated bounding volume from the bind
275+
// pose positions and the current skinning matrices so culling is correct.
276+
for (Geometry geometry : targets) {
277+
Mesh mesh = geometry.getMesh();
278+
if (mesh != null && mesh.isAnimated()) {
279+
updateSkinnedMeshBound(geometry, mesh, offsetMatrices);
280+
}
281+
}
268282
}
269283

270284
@Override
@@ -703,6 +717,108 @@ private void applySkinningTangents(Mesh mesh, Matrix4f[] offsetMatrices, VertexB
703717
tb.updateData(ftb);
704718
}
705719

720+
/**
721+
* Computes the bounding volume of an animated mesh from the bind pose
722+
* positions and the current skinning matrices, then sets it on the geometry.
723+
* This is used during hardware skinning to keep culling correct, since the
724+
* GPU-transformed vertex positions are not reflected in the CPU-side vertex
725+
* buffer.
726+
*
727+
* @param geometry the geometry whose bound needs to be updated
728+
* @param mesh the animated mesh
729+
* @param offsetMatrices the bone offset matrices for this frame
730+
*/
731+
private static void updateSkinnedMeshBound(Geometry geometry, Mesh mesh,
732+
Matrix4f[] offsetMatrices) {
733+
VertexBuffer bindPosVB = mesh.getBuffer(Type.BindPosePosition);
734+
if (bindPosVB == null) {
735+
return;
736+
}
737+
VertexBuffer boneIndexVB = mesh.getBuffer(Type.BoneIndex);
738+
VertexBuffer boneWeightVB = mesh.getBuffer(Type.BoneWeight);
739+
if (boneIndexVB == null || boneWeightVB == null) {
740+
return;
741+
}
742+
int maxWeightsPerVert = mesh.getMaxNumWeights();
743+
if (maxWeightsPerVert <= 0) {
744+
return;
745+
}
746+
int fourMinusMaxWeights = 4 - maxWeightsPerVert;
747+
748+
FloatBuffer bindPos = (FloatBuffer) bindPosVB.getData();
749+
bindPos.rewind();
750+
IndexBuffer boneIndex = IndexBuffer.wrapIndexBuffer(boneIndexVB.getData());
751+
FloatBuffer boneWeightBuf = (FloatBuffer) boneWeightVB.getData();
752+
boneWeightBuf.rewind();
753+
// Use array() when available (heap buffer), otherwise copy to a local array.
754+
float[] weights;
755+
if (boneWeightBuf.hasArray()) {
756+
weights = boneWeightBuf.array();
757+
} else {
758+
weights = new float[boneWeightBuf.limit()];
759+
boneWeightBuf.get(weights);
760+
}
761+
int idxWeights = 0;
762+
763+
int numVerts = bindPos.limit() / 3;
764+
float minX = Float.POSITIVE_INFINITY, minY = Float.POSITIVE_INFINITY,
765+
minZ = Float.POSITIVE_INFINITY;
766+
float maxX = Float.NEGATIVE_INFINITY, maxY = Float.NEGATIVE_INFINITY,
767+
maxZ = Float.NEGATIVE_INFINITY;
768+
769+
for (int v = 0; v < numVerts; v++) {
770+
float vtx = bindPos.get();
771+
float vty = bindPos.get();
772+
float vtz = bindPos.get();
773+
774+
float rx, ry, rz;
775+
if (weights[idxWeights] == 0) {
776+
idxWeights += 4;
777+
rx = vtx;
778+
ry = vty;
779+
rz = vtz;
780+
} else {
781+
rx = 0;
782+
ry = 0;
783+
rz = 0;
784+
for (int w = 0; w < maxWeightsPerVert; w++) {
785+
float weight = weights[idxWeights];
786+
Matrix4f mat = offsetMatrices[boneIndex.get(idxWeights++)];
787+
rx += (mat.m00 * vtx + mat.m01 * vty + mat.m02 * vtz + mat.m03) * weight;
788+
ry += (mat.m10 * vtx + mat.m11 * vty + mat.m12 * vtz + mat.m13) * weight;
789+
rz += (mat.m20 * vtx + mat.m21 * vty + mat.m22 * vtz + mat.m23) * weight;
790+
}
791+
idxWeights += fourMinusMaxWeights;
792+
}
793+
794+
if (rx < minX) minX = rx;
795+
if (rx > maxX) maxX = rx;
796+
if (ry < minY) minY = ry;
797+
if (ry > maxY) maxY = ry;
798+
if (rz < minZ) minZ = rz;
799+
if (rz > maxZ) maxZ = rz;
800+
}
801+
802+
// Reuse the existing BoundingBox if possible to avoid allocation.
803+
BoundingVolume bv = mesh.getBound();
804+
BoundingBox bbox;
805+
if (bv instanceof BoundingBox) {
806+
bbox = (BoundingBox) bv;
807+
} else {
808+
bbox = new BoundingBox();
809+
}
810+
TempVars vars = TempVars.get();
811+
try {
812+
vars.vect1.set(minX, minY, minZ);
813+
vars.vect2.set(maxX, maxY, maxZ);
814+
bbox.setMinMax(vars.vect1, vars.vect2);
815+
} finally {
816+
vars.release();
817+
}
818+
// setModelBound() updates the mesh bound and triggers a world-bound refresh.
819+
geometry.setModelBound(bbox);
820+
}
821+
706822
@Override
707823
public void write(JmeExporter ex) throws IOException {
708824
super.write(ex);

jme3-core/src/test/java/com/jme3/test/PreventCoreIssueRegressions.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import com.jme3.app.state.ScreenshotAppState;
4141
import com.jme3.asset.AssetManager;
4242
import com.jme3.asset.DesktopAssetManager;
43+
import com.jme3.bounding.BoundingVolume;
4344
import com.jme3.input.InputManager;
4445
import com.jme3.input.dummy.DummyKeyInput;
4546
import com.jme3.input.dummy.DummyMouseInput;
@@ -130,4 +131,47 @@ public void testIssue1138() {
130131
Vector3f.isValidVector(joint.getLocalTranslation()));
131132
}
132133
}
134+
135+
/**
136+
* Test case for JME issue #343: Animated models should have proper model bound.
137+
*
138+
* <p>When software skinning is used, calling controlRender should update the
139+
* bounding volumes of the animated geometries to reflect their current pose.
140+
*/
141+
@Test
142+
public void testIssue343() {
143+
AssetManager am = JmeSystem.newAssetManager(
144+
PreventCoreIssueRegressions.class.getResource("/com/jme3/asset/Desktop.cfg"));
145+
Node cgModel = (Node) am.loadModel("Models/Elephant/Elephant.mesh.xml");
146+
cgModel.scale(0.04f);
147+
148+
AnimComposer composer = cgModel.getControl(AnimComposer.class);
149+
SkinningControl sControl = cgModel.getControl(SkinningControl.class);
150+
151+
// Force software skinning so bounds are computed from CPU vertex positions.
152+
sControl.setHardwareSkinningPreferred(false);
153+
154+
// Record the world bound in the bind pose.
155+
cgModel.updateGeometricState();
156+
BoundingVolume bindPoseBound = cgModel.getWorldBound().clone();
157+
158+
// Advance the "legUp" animation, which raises a leg well beyond the bind pose.
159+
composer.setCurrentAction("legUp");
160+
cgModel.updateLogicalState(0.5f);
161+
162+
// Simulate the render pass: controlRender applies software skinning and
163+
// calls geometry.updateModelBound() on each target.
164+
RenderManager rm = new RenderManager(new NullRenderer());
165+
ViewPort vp = rm.createMainView("test", new Camera(1, 1));
166+
sControl.render(rm, vp);
167+
168+
// Propagate the refreshed bounds up the scene graph.
169+
cgModel.updateGeometricState();
170+
BoundingVolume animatedBound = cgModel.getWorldBound().clone();
171+
172+
// The bounding volume must differ from the bind pose bound.
173+
Assert.assertFalse(
174+
"Model bound should change after animation is applied via software skinning",
175+
bindPoseBound.equals(animatedBound));
176+
}
133177
}

0 commit comments

Comments
 (0)