[SOLVED] Local translation affected when parent node rotation set to (0,0,0,0.5f)

Hi everyone,

I have spent the last couple of days trying to track down a fiendish bug in my game, and while I think I’ve found the cause, I would like to understand it more deeply.

Occasionally, for no apparent reason, my TestPlayers would spontaneously go from looking like this:


to collapsing into a heap:
image

When I debugged the local translations and bounds, everything looked fine. I looked at threading, local and world transforms, code interfering with the refreshFlags, added breakpoints and logging everywhere…

Eventually, I noticed that a rotation packet was being received that set the player node’s local rotation to the quaternion (0,0,0,0.5f), and it turns out that this seems to cause JME to ignore the local translation of children of the player node.

My question is: why does that happen? (and is it an invalid quaternion?)

Many thanks,

Duncan.

P.S. I have a SSCE that replicates the problem - press ‘B’ to toggle the bug:

package com.codealchemists.jme.test;

import com.jme3.app.SimpleApplication;
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.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.scene.Geometry;
import com.jme3.scene.Node;
import com.jme3.scene.shape.Box;
import com.jme3.system.AppSettings;

// Shows geometry local translations 'collapsing' when local rotation of parent node is set to (0,0,0,0.5f).
// Press 'B' to toggle the bug.
public class TestGeometryCollapseBug extends SimpleApplication {
    private Node node;
    private boolean usingBuggyQuaternion = false;

    public static void main(String[] args) {
        TestGeometryCollapseBug app = new TestGeometryCollapseBug();
        app.setShowSettings(false);  // disable the initial settings dialog window
        AppSettings appSettings =new AppSettings(true);
        appSettings.setWidth(1024);
        appSettings.setHeight(768);

        app.setSettings(appSettings);
        app.start();
    }

    @Override
    public void simpleInitApp() {

        addDirectionalLight();
        addAmbientLight();

        Material mat = createLightingMaterial(ColorRGBA.Red);

        node = createPlayerEntityNode(mat);
        rootNode.attachChild(node);

        flyCam.setMoveSpeed(30);

        inputManager.addListener(new ActionListener() {

            @Override
            public void onAction(String name, boolean isPressed, float tpf) {
                if (isPressed) {
                    if (name.equals("toggle")) {
                        usingBuggyQuaternion = !usingBuggyQuaternion;
                        if( usingBuggyQuaternion ) {
                            node.setLocalRotation(new Quaternion(0,0,0,0.5f));
                        } else {
                            node.setLocalRotation(new Quaternion(0,0,0,1.0f));
                        }
                        System.out.println("Set local rotation to "+node.getLocalRotation());
                    }
                }
            }
        }, "toggle");


        inputManager.addMapping("toggle", new KeyTrigger(keyInput.KEY_B));

        System.out.println("Press 'B' to toggle the bug on/off.");
        System.out.println("Node local rotation: "+node.getLocalRotation());
    }

    // please ignore the detail of this - it's just experimental code that creates a boxy robot-like entity for testing.
    private Node createPlayerEntityNode(Material mat) {
        String nodeIDSuffix = "42";
        Node node = new Node("playerEntityNode-" + nodeIDSuffix);

        Node topNode = new Node("playerEntityNode-" + nodeIDSuffix + "-scale");
        topNode.setLocalTranslation(0, 2.5f, 0);  // so that the rest of the nodes are shifted up above the feet.
        topNode.setLocalScale(0.5f);
        node.attachChild(topNode);


        Node body = new Node("playerEntity-" + nodeIDSuffix + "-body");
        topNode.attachChild(body);


        Geometry headGeometry = new Geometry("playerEntity-" + nodeIDSuffix + "-head", new Box(0.5f, 0.5f, 0.35f));
        headGeometry.center();
        headGeometry.setMaterial(mat);
        headGeometry.move(0, 0.3f, 0);
        topNode.attachChild(headGeometry);

        Geometry torsoGeometry = new Geometry("playerEntity-" + nodeIDSuffix + "-torso", new Box(0.7f, 1.1f, 0.4f));
        torsoGeometry.center();
        torsoGeometry.setMaterial(mat);
        torsoGeometry.setLocalTranslation(0, -1.3f, 0);
        body.attachChild(torsoGeometry);

        Geometry leftLegGeometry = new Geometry("playerEntity-" + nodeIDSuffix + "-leftLeg", new Box(0.3f, 1.30f, 0.3f));
        leftLegGeometry.center();
        leftLegGeometry.setMaterial(mat);
        leftLegGeometry.move(-0.35f, -3.7f, 0);
        body.attachChild(leftLegGeometry);

        Geometry rightLegGeometry = new Geometry("playerEntity-" + nodeIDSuffix + "-leftLeg", new Box(0.3f, 1.30f, 0.3f));
        rightLegGeometry.center();
        rightLegGeometry.setMaterial(mat);
        rightLegGeometry.move(+0.35f, -3.7f, 0);
        body.attachChild(rightLegGeometry);

        return node;
    }

    private Material createLightingMaterial(ColorRGBA color) {
        Material material = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
        material.setBoolean("UseMaterialColors", true);
        material.setColor("Ambient", color);
        material.setColor("Diffuse", color);
        material.setColor("Specular", color);
        material.setFloat("Shininess", 1.0f);
        return material;
    }

    private void addAmbientLight() {
        AmbientLight ambientLight = new AmbientLight(new ColorRGBA(0.5f, 0.5f, 0.5f, 1.0f));
        rootNode.addLight(ambientLight);
    }

    private void addDirectionalLight() {
        DirectionalLight light = new DirectionalLight();

        light.setColor(ColorRGBA.White);
        light.setDirection(new Vector3f(-1, -1, -1));

        rootNode.addLight(light);
    }
}

1 Like

Probably not ignored but grossly flattened. Since you didn’t log the world translation relative to parent, it’s hard to say. I suspect they still have transforms but they are small.

Often times a nonsense-quaternion multiplied by a good quaternion will lead to a nonsense quaternion result… and it’s hard to predict how that will play out.

By what you mean by “invalid quaternion” then no, it’s not a valid quaternion… meaning that it does not represent a valid rotation.

How did you get it?

1 Like

Yes, you’re right. When I modify the SSCE to print the world translation of that player node (and children, recursively), I observe:

Set local rotation to (0.0, 0.0, 0.0, 0.5)
(0.0, 0.0, 0.0)
   (0.0, 0.625, 0.0)
      (0.0, 0.625, 0.0)
         (0.0, 0.4625, 0.0)
         (-0.04375, 0.1625, 0.0)
         (0.04375, 0.1625, 0.0)
      (0.0, 0.6625, 0.0)

Set local rotation to (0.0, 0.0, 0.0, 1.0)
(0.0, 0.0, 0.0)
   (0.0, 2.5, 0.0)
      (0.0, 2.5, 0.0)
         (0.0, 1.85, 0.0)
         (-0.175, 0.65, 0.0)
         (0.175, 0.65, 0.0)
      (0.0, 2.65, 0.0)

You can see the Y component of the world translation is much smaller in the children for the first example (bug) as opposed to the second example (not bug).

It was a pretty buggy scenario - two entities were trying to move to attack each other, and ended up occupying the same location (no server-side physics yet to keep them apart). Since their locations were the same, the resulting vector for movement was (0,0,0). Then the attacker tried to compute the rotation required to face its opponent using:

rotation.lookAt(motionVector, Vector3f.UNIT_Y);

which sets the rotation quaternion to (0,0,0,0.5f).

I know it’s a silly situation to get into in the first place, as I can easily a) prevent entities from occupying the same space and/or b) avoid turning if that motionVector is zero - but is there anything we can improve here in JME? It wasn’t obvious that a node’s rotation could affect the translations of all the children of a node in this way. That might just be me though!

I mean… for a scene graph, the parent’s rotation does little else BUT affect the childrens’ translation. So I’m not sure what to tell you there.

JME avoids adding 50,000,000 checks all over the place just to save folks from themselves and assumes that they will eventually find their bugs and then would prefer the speed of not performing 50,000,000 now-unnecessary checks.

Else there isn’t much that can be done about wild nonsense quaternions.

Note: if you’d normalized the quaternion then it would have fixed the issue… but really hidden a logic bug in your code.

Generally, here is the process for “gee, things aren’t where they are supposed to be”:
Step 1: log “all the things” image
(in this case, the transforms that look wrong… not just the translations but the whole transform)
Step 2: walk backwards to see why they are strange… check that quaternions are length 1, etc.

I believe you’d have found your issue right away then.

By the way, in the realm of “common pitfalls” that the grizzled veterans nearly automatically avoid, there are a few edge cases where lookAt() doesn’t do nice things:

  1. when motionVector is length 0
  2. when motionVector and UNIT_Y are parallel

So it’s common to remember to avoid these cases logically or to check and account for them before calling lookAt(). (For example, if it’s always in the back of your mind that lookAt() cannot take a 0 length vector then your logic up to that point might have accounted for it.)

One could make the argument that maybe lookAt() could at least assert() these cases but 99% of the time folks seem to run without assertions on anyway.

1 Like

Chuckle :slight_smile: You’re right of course - I really meant the local/visual translations. So for example, fiddling with that last component of the quaternion can cause the different child spatials to explode away from each other, or collapse into each other (visually). At my basic level of understanding, I’m setting a rotation, which should rotate the player spatials around their node, not cause them to translate outwards (or inwards) relative to each other.

For a novice like me, a build of JME that could log warnings (or even assert) when things are weird would save a lot of debugging time. I appreciate that would probably have a negative performance impact, unless we have something like a static final flag and rely on the compiler to optimize out all of this checking code for the production build. And managing two sets of builds would be more pain. Or - yes, assertions. I use Minie physics, so I run with assertions enabled for this kind of reason.

As for logging etc - yep - I did log all the things and spent 3 days searching the resulting haystacks for needles. I added breakpoints and tried to figure out how to reproduce the problem so that I could observe what was changing. As it turned out, I ended up going up several wrong turns and dead ends before eventually looking at rotations. As a beginner, I wasn’t really sure how the transforms should look when things were correct, so it was hard to spot that there was a problem - and I didn’t know about quaternions being normalised like vectors. I’ve learned a lot more about JME internals as a result though.

As always, thanks for the replies, and even if we never add assertions to JME, hopefully the next person with this problem will find this post and gain a little help from it.

Raised a tiny PR to add a note to the javadoc for Quaternion.lookAt:

1 Like

Just in case: One lesson it takes some folks a while to latch onto (not sure if it’s true in your case or not) is that Quaternion values are not meant to be “fiddled with”. A quaternion rotation is a 4-component vector on a 4-dimensional unit sphere that “uniquely” represents a change in orientation. Messing with any single value must necessarily affect all of the other 3… and not in any logical way that is easy for the brain to understand.

Anyway, about the best you can do to check for a valid rotation quaternion is check its length. length = 1 will be some valid rotation even if it’s not the rotation that’s intended. Anything else may not be a valid rotation and so when multiplied by some position may produce strange results.

Also, JME is accepting a quaternion as a rotation not because “Oh, this is the rotation the user wants and I will do all of this other math to rotate points”… but because “quaternion is the math/mechanism that I will use to rotate other points”. It’s doing yourQuaternion.mult(vertex).