Lemur Gems #1 : InputMapper based camera movement

So, as some of you may know, Lemur is a light-weight GUI library that lets you make labels, buttons, and other twiddly bits in either 2D or 3D user interfaces.

The core design of Lemur was to build several modular toolkits that together form a full-up GUI library. Consequently, some of these toolkits have great utility even if you choose not to use the GUI elements themselves. This Gem covers one of these: InputMapper

The whole project can be found here: https://github.com/jMonkeyEngine-Contributions/Lemur/tree/master/examples/LemurGems

InputMapper

InputMapper wraps JME’s normal InputManager to provide more robust input mapping. Some of it’s highlight features are:

  • Total decoupling of input mapping and input processing.
  • Ability to map additional inputs to an existing mapping.
  • Ability to map input combinations to existing mappings (shift+w, ctrl+mouse wheel, etc.)
  • Ability to treat ‘binary’ style state inputs as analog axes. (So ‘w’ and ‘s’ can map to a single analog ‘axis’.)
  • Function groups for toggling input sets on and off.

To demonstrate these features I will provide a fly-cam style example using InputMapper input processing.

Key Concepts

The logical mapping from a set of inputs to a set of listeners is done through a FunctionId. So on the one hand, code could map the mouse X axis to FunctionId(“Mouse Look X”) and on the other hand, some other code can listen for events for FunctionId(“Mouse Look X”). The registration of one or the other can be done in any order and additional input mappings can be added to FunctionId(“Mouse Look X”) at any time.

FunctionId contains an optional group designation. FunctionId(“Movement”, “Mouse Look X”) These groups can be activated or deactivated at the same time.

While your app may choose to define the FunctionIds, inputs, and listeners all in one place, I prefer to split the FunctionIds and default mappings into a separate class to make the separation clearer. I’ve done that here. The other benefit is that it makes it easier for other code to add listeners to the same functions if it chooses… or multiple app states could map to the same function IDs recognizing that only one state would be used at a time and so on.

To me, keeping the logical IDs separate is a good practice but it is by no means required.

In this example, all of the camera movement function Ids are contained in a class called… wait for it… CameraMovementFunctions. The full class can be seen here: http://code.google.com/p/jmonkeyplatform-contributions/source/browse/trunk/Lemur/examples/LemurGems/src/gems/CameraMovementFunctions.java

I’ll include some relevant excerpts below:

public class CameraMovementFunctions {

    public static final String GROUP_MOVEMENT = "Movement";

    public static final FunctionId F_Y_LOOK = new FunctionId(GROUP_MOVEMENT, "Y Look");
    public static final FunctionId F_X_LOOK = new FunctionId(GROUP_MOVEMENT, "X Look");

    public static final FunctionId F_MOVE = new FunctionId(GROUP_MOVEMENT, "Move");
    public static final FunctionId F_STRAFE = new FunctionId(GROUP_MOVEMENT, "Strafe");

    ....snip....
    
    public static void initializeDefaultMappings( InputMapper inputMapper )
    {
        // The joystick Y axes are backwards on game pads... forward
        // is negative.  So we'll flip it over in the mapping.
        inputMapper.map( F_MOVE, InputState.Negative, Axis.JOYSTICK_LEFT_Y );
        
        // Here a similar approach is used to map the W and S keys with
        // 'S' being negative.  In this way, W and S now act like a joystick
        // axis.
        inputMapper.map( F_MOVE, KeyInput.KEY_W );
        inputMapper.map( F_MOVE, InputState.Negative, KeyInput.KEY_S );
        
        // Strafing is setup similar to move.
        inputMapper.map( F_STRAFE, Axis.JOYSTICK_LEFT_X );
        inputMapper.map( F_STRAFE, KeyInput.KEY_D );
        inputMapper.map( F_STRAFE, InputState.Negative, KeyInput.KEY_A );

        ...snip....
        inputMapper.map( F_RUN, KeyInput.KEY_LSHIFT );
        ...snip....
    }
}

It’s worth clicking through the link to view the whole file as there a bunch of comments and some additional mappings that I’ve snipped out of the above.

    The primary purpose of this class is to define some logical FunctionIds. The secondary purpose is to provide some default input mappings. The calling code can choose to call this initializeDefaultMappings() method or not based on need. Maybe it wants to define its own inputs instead. The functions stay the same.
      This is similar to JME's trigger names but a little more flexible.
        Listeners
          InputMapper accepts either AnalogFunctionListener or StateFunctionListener. These are similar to the listeners in JME's InputManager with some distinctions. First, none of the values are premultiplied by tpf. It's up to you to do that.
            Second, instead of a "boolean" state there is now an InputState enum. StateFunctionListener's method looks like:
              public void valueChanged( FunctionId func, InputState value, double tpf )
                InputState has three possible values: Positive, Off, and Negative.
                  All analog or binary inputs will trigger both types of inputs. So a joystick axis can trigger a StateFunctionListener and you'd see Positive, Off, or Negative depending on the joystick position. Likewise, 'binary' inputs like keys will trigger analog events during the entire time they are down. Similar to regular JME, these values will look like 1 or -1.
                    So without further delay, here is the CameraMovementState. http://code.google.com/p/jmonkeyplatform-contributions/source/browse/trunk/Lemur/examples/LemurGems/src/gems/CameraMovementState.java ```Java public class CameraMovementState extends BaseAppState implements AnalogFunctionListener, StateFunctionListener {
                    private InputMapper inputMapper;
                    private Camera camera;
                    private double turnSpeed = 2.5;  // one half complete revolution in 2.5 seconds
                    private double yaw = FastMath.PI;
                    private double pitch;
                    private double maxPitch = FastMath.HALF_PI;
                    private double minPitch = -FastMath.HALF_PI;
                    private Quaternion cameraFacing = new Quaternion().fromAngles((float)pitch, (float)yaw, 0);
                    private double forward;
                    private double side;
                    private double elevation;
                    private double speed = 3.0;
                    
                    public CameraMovementState( boolean enabled ) {
                        setEnabled(enabled);
                    }
                    
                    public void setPitch( double pitch ) {
                        this.pitch = pitch;
                        updateFacing();
                    }
                    
                    public double getPitch() {
                        return pitch;
                    }
                    
                    public void setYaw( double yaw ) {
                        this.yaw = yaw;
                        updateFacing();
                    }
                    
                    public double getYaw() {
                        return yaw;
                    }
                    
                    public void setRotation( Quaternion rotation ) {
                        // Do our best
                        float[] angle = rotation.toAngles(null);
                        this.pitch = angle[0];
                        this.yaw = angle[1];
                        updateFacing();
                    }
                    
                    public Quaternion getRotation() {
                        return camera.getRotation();
                    }
                    
                    @Override
                    protected void initialize(Application app) {
                        this.camera = app.getCamera();
                        
                        if( inputMapper == null )
                            inputMapper = GuiGlobals.getInstance().getInputMapper();
                        
                        // Most of the movement functions are treated as analog.        
                        inputMapper.addAnalogListener(this,
                                                      CameraMovementFunctions.F_Y_LOOK,
                                                      CameraMovementFunctions.F_X_LOOK,
                                                      CameraMovementFunctions.F_MOVE,
                                                      CameraMovementFunctions.F_ELEVATE,
                                                      CameraMovementFunctions.F_STRAFE);
                    
                        // Only run mode is treated as a 'state' or a trinary value.
                        // (Positive, Off, Negative) and in this case we only care about
                        // Positive and Off.  See CameraMovementFunctions for a description
                        // of alternate ways this could have been done.
                        inputMapper.addStateListener(this,
                                                     CameraMovementFunctions.F_RUN);
                    }
                    
                    @Override
                    protected void cleanup(Application app) {
                    
                        inputMapper.removeAnalogListener( this,
                                                          CameraMovementFunctions.F_Y_LOOK,
                                                          CameraMovementFunctions.F_X_LOOK,
                                                          CameraMovementFunctions.F_MOVE,
                                                          CameraMovementFunctions.F_ELEVATE,
                                                          CameraMovementFunctions.F_STRAFE);
                        inputMapper.removeStateListener( this,
                                                         CameraMovementFunctions.F_RUN);
                    }
                    
                    @Override
                    protected void enable() {
                        // Make sure our input group is enabled
                        inputMapper.activateGroup( CameraMovementFunctions.GROUP_MOVEMENT );
                        
                        // And kill the cursor
                        GuiGlobals.getInstance().setCursorEventsEnabled(false);
                        
                        // A 'bug' in Lemur causes it to miss turning the cursor off if
                        // we are enabled before the MouseAppState is initialized.
                        getApplication().getInputManager().setCursorVisible(false);        
                    }
                    
                    @Override
                    protected void disable() {
                        inputMapper.deactivateGroup( CameraMovementFunctions.GROUP_MOVEMENT );
                        GuiGlobals.getInstance().setCursorEventsEnabled(true);        
                    }
                    
                    @Override
                    public void update( float tpf ) {
                    
                        // 'integrate' camera position based on the current move, strafe,
                        // and elevation speeds.
                        if( forward != 0 || side != 0 || elevation != 0 ) {
                            Vector3f loc = camera.getLocation();
                            
                            Quaternion rot = camera.getRotation();
                            Vector3f move = rot.mult(Vector3f.UNIT_Z).multLocal((float)(forward * speed * tpf)); 
                            Vector3f strafe = rot.mult(Vector3f.UNIT_X).multLocal((float)(side * speed * tpf));
                            
                            // Note: this camera moves 'elevation' along the camera's current up
                            // vector because I find it more intuitive in free flight.
                            Vector3f elev = rot.mult(Vector3f.UNIT_Y).multLocal((float)(elevation * speed * tpf));
                                        
                            loc = loc.add(move).add(strafe).add(elev);
                            camera.setLocation(loc); 
                        }
                    }
                    
                    /**
                     *  Implementation of the StateFunctionListener interface.
                     */
                    @Override
                    public void valueChanged( FunctionId func, InputState value, double tpf ) {
                    
                        // Change the speed based on the current run mode
                        // Another option would have been to use the value
                        // directly:
                        //    speed = 3 + value.asNumber() * 5
                        //...but I felt it was slightly less clear here.   
                        boolean b = value == InputState.Positive;
                        if( func == CameraMovementFunctions.F_RUN ) {
                            if( b ) {
                                speed = 10;
                            } else {
                                speed = 3;
                            }
                        }
                    }
                    
                    /**
                     *  Implementation of the AnalogFunctionListener interface.
                     */
                    @Override
                    public void valueActive( FunctionId func, double value, double tpf ) {
                    
                        // Setup rotations and movements speeds based on current
                        // axes states.    
                        if( func == CameraMovementFunctions.F_Y_LOOK ) {
                            pitch += -value * tpf * turnSpeed;
                            if( pitch < minPitch )
                                pitch = minPitch;
                            if( pitch > maxPitch )
                                pitch = maxPitch;
                        } else if( func == CameraMovementFunctions.F_X_LOOK ) {
                            yaw += -value * tpf * turnSpeed;
                            if( yaw < 0 )
                                yaw += Math.PI * 2;
                            if( yaw > Math.PI * 2 )
                                yaw -= Math.PI * 2;
                        } else if( func == CameraMovementFunctions.F_MOVE ) {
                            this.forward = value;
                            return;
                        } else if( func == CameraMovementFunctions.F_STRAFE ) {
                            this.side = -value;
                            return;
                        } else if( func == CameraMovementFunctions.F_ELEVATE ) {
                            this.elevation = value;
                            return;
                        } else {
                            return;
                        }
                        updateFacing();        
                    }
                    
                    protected void updateFacing() {
                        cameraFacing.fromAngles( (float)pitch, (float)yaw, 0 );
                        camera.setRotation(cameraFacing);
                    }
                    

                    }

                    
                    The simple main class included in the project shows how this can be used:
                    http://code.google.com/p/jmonkeyplatform-contributions/source/browse/trunk/Lemur/examples/LemurGems/src/gems/Main.java
                    <ul></ul>
                    Here are the most important bits:
                    ```Java
                    ....snip...
                        public Main() {
                            super(new StatsAppState(), new CameraMovementState(true));
                        }
                    
                        @Override
                        public void simpleInitApp() {
                        
                            // Let Lemur's GuiGlobals initialize a lot of goodies for us.
                            // This will also setup a static InputMapper that we can reference.
                            // An alternative would have been to create and manage our own
                            // InputMapper instance instead... but later tutorials will rely
                            // on other parts of Lemur so we might as well initialize it now.
                            GuiGlobals.initialize(this);
                            
                            // The camera input mappings and the camera input handling have
                            // been separated for clarity but this means that by default
                            // the CameraMovementState won't be provided any input.
                            // We have to map some inputs to its functions:
                            CameraMovementFunctions.initializeDefaultMappings(GuiGlobals.getInstance().getInputMapper());
                    ....snip...
                    

                    Essentiially:

                    1. attach the app state
                    2. make sure InputMapper is initialized (in this case it’s done automatically by GuiGlobals.initialize())
                    3. setup some mappings

                    Some things I didn’t cover that are left as exercises for the eager reader:

                    • key combos
                    • scaling of input mapping to provide inverting or adjusting sensitivity. (Mouse look y-flip, mouse sensitivity, etc.)
                    • probably some other things I forgot
                    10 Likes

                    Note: I cut-pasted the Main.java above into CameraDemo.java:
                    http://code.google.com/p/jmonkeyplatform-contributions/source/browse/trunk/Lemur/examples/LemurGems/src/gems/CameraDemo.java

                    Main.java will evolve into a demo that includes all/most of the gems which individual demo files illustrating specific gem tutorials.

                    2 Likes

                    @pspeed Can we add button.ispressed() function to input map like Keyinputs?

                    I’m not sure what you mean here. What is it that you are trying to do?

                    I want to control my car on android and move it with arrow buttons.Can i add buttons to inputmaps(like keys) so when they are pressed car can move?Or should i control if buttons are pressed in update loop and move car?

                    You can add commands to your button that get invoked when you press or release the button. Then just wire those to your car.

                    But maybe I’m not understanding what the issue is because that seems really simple and logical… so there may be some aspect of your question I don’t see.

                    1 Like

                    We can use keyboard keys on inputmapper like
                    inputMapper.map( F_MOVE, KeyInput.KEY_W );
                    and i wanted to learn if Lemur has a feature like writing button name instead of KeyInput.KEY_W.And buttons has inputmappers :grinning: .

                    No… but that doesn’t really make sense.

                    The point of InputMapper is to abstract the logical mapping of inputs to actions. You won’t be remapping your GUI buttons to some other GUI buttons later. Just add your “move my thing” listener directly to your Button objects with a command adapter or something.

                    Folks tend to always want to inject events at a higher level than necessary for some reason. There are no upsides to this approach but many down sides. The only perceived upside “my code will be simpler” is actually a down side because “no it won’t, really” and it ties you into some nasty restrictions you will regret later.