Some things about Tamarin

Hello

  1. I borrowed a VR glass and played around with Tamarin. But how to connect it to the physics?
    My first idea was to control the observer with a CharacterControl, but that would not work with players moving around. The only possibility that comes to mind would be to move a shape on prePhysicsTick and test collisions in physicsTick and maybe move the shape and the camera back. But I think that won’t work since the game loop will run faster than the physics thread.

  2. When gripping a hand while it’s over a terrain, the application crashes (only tested in Desktop emulation mode).

java.lang.NullPointerException: Cannot invoke "com.jme3.scene.Geometry.getUserData(String)" because the return value of "com.jme3.collision.CollisionResult.getGeometry()" is null
	at com.onemillionworlds.tamarin.vrhands.BoundHand.pickGrab(BoundHand.java:513)
	at com.onemillionworlds.tamarin.vrhands.functions.GrabPickingFunction.update(GrabPickingFunction.java:75)
	at com.onemillionworlds.tamarin.vrhands.BoundHand.lambda$update$7(BoundHand.java:356)
	at java.base/java.util.concurrent.CopyOnWriteArrayList.forEach(CopyOnWriteArrayList.java:891)
	at com.onemillionworlds.tamarin.vrhands.BoundHand.update(BoundHand.java:356)
	at com.onemillionworlds.tamarin.vrhands.VRHandsAppState.lambda$update$2(VRHandsAppState.java:155)
	at java.base/java.util.Optional.ifPresent(Optional.java:178)
	at com.onemillionworlds.tamarin.vrhands.VRHandsAppState.update(VRHandsAppState.java:154)
	at com.jme3.app.state.AppStateManager.update(AppStateManager.java:371)
	at com.jme3.app.SimpleApplication.update(SimpleApplication.java:258)
	at com.jme3.system.lwjgl.LwjglWindow.runLoop(LwjglWindow.java:628)
	at com.jme3.system.lwjgl.LwjglWindow.run(LwjglWindow.java:717)
	at java.base/java.lang.Thread.run(Thread.java:1583)
  1. The current distributed file tamarin-2.6.1-sources.jar doesn’t contain the right sources for tamarin-2.6.1.jar. For example, line BoundHand.java:513 in that archive is a Javadoc comment.
    I built a fresh source archive from github:
    193.5 KB file on MEGA

  2. MovingPlayerExampleState:154 in the TamarinTestBed throws with the Quest 2 an exception. The rest of the example works as expected.

com.onemillionworlds.tamarin.openxr.OpenXrSessionManager$OpenXrException: XR_ERROR_PATH_INVALID
	at com.onemillionworlds.tamarin.openxr.OpenXrSessionManager.checkResponseCode(OpenXrSessionManager.java:955)
	at com.onemillionworlds.tamarin.openxr.OpenXrSessionManager.checkResponseCode(OpenXrSessionManager.java:941)
	at com.onemillionworlds.tamarin.actions.XrActionAppState.checkResponseCode(XrActionAppState.java:493)
	at com.onemillionworlds.tamarin.actions.XrActionAppState.longToPath(XrActionAppState.java:821)
	at com.onemillionworlds.tamarin.actions.XrActionAppState.getPhysicalBindingForAction(XrActionAppState.java:237)
	at example.MovingPlayerExampleState.initialiseScene(MovingPlayerExampleState.java:154)
	at example.MovingPlayerExampleState.initialize(MovingPlayerExampleState.java:51)
	at com.jme3.app.state.BaseAppState.initialize(BaseAppState.java:129)
	at com.jme3.app.state.AppStateManager.initializePending(AppStateManager.java:332)
	at com.jme3.app.state.AppStateManager.update(AppStateManager.java:362)
	at com.jme3.app.SimpleApplication.update(SimpleApplication.java:260)
	at com.jme3.system.lwjgl.LwjglWindow.runLoop(LwjglWindow.java:629)
	at com.jme3.system.lwjgl.LwjglWindow.run(LwjglWindow.java:719)
	at java.base/java.lang.Thread.run(Thread.java:1583)

Hi, thanks for letting me know. I’m out and about right now but will take a look when I get back

2 Likes

I have a minie physics demo half done. I’ll try to get it finished tomorrow and add it to the test bed examples (I had capsule based walking and falling working fine, hand interactions may or may not be included)

Interesting that a collision result wouldn’t have a geometry, but looking at the CollisionResult api it is actually allowed. What sort of scene caused this? I’ve released a new version 2.6.2 that shouldn’t have this problem (although as I didn’t reproduce the problem locally could you check?). Version was just released, should be available through gradle in the next few minutes

Coincidentally I noticed that this morning. I wonder if lombok is to blame (I’ve had that problem before). I’ll have an investigate (for the avoidance of doubt, the version I just released will probably also have this problem as I wanted to get the bug fix out before investigating the sources problem)

I’ve got a Quest 2 and that works for me with walk binding being

PhysicalBindingInterpretation[rawValue=/user/hand/right/input/joystick/x, handSide=Optional[RIGHT], fundamentalButton=joystick, withinButtonAction=x]

Could you tell me some more about your set up? I’m using Virtual desktop and a steam VR runtime.

1 Like

I have updated TamarinTestBed with a new example that is a physics integration but I think it is the wrong approach. It treats the player as a physics object with loads of nasty side effects:

  • Leaning over an edge leads to you falling off the edge (as when your head moves over the edge your body moves over the edge and you loose your footing)
  • When you physically move your head into a wall the player gets “pushed back out of collision” but that feels like the world pushes away from you. Very very nausea inducing. Some games do do this (I’m looking at you “into the radius”) but I would strongly advise against it
  • When you lean over a table you jump up onto it (basically the issue with leaning over an edge in reverse).

I have a plan and I’m working on something that won’t have those issues but it isn’t finished yet. What I’m planning on is:

  • If you stick your head into a wall your head is allowed to do that but your view is occluded (like in Half Life Alyx - which is usually my go-to for deciding what the right thing to do is)
  • When you try to move with your joystick; motion may be vetoed if it would move you into collision
  • If joystick motion (and only joystick motion) brings you into collision with a step or over an edge your height may be adjusted or you may begin falling
  • Hand bones are kinematic shapes in the physics engine, they are allowed to be where ever they like and affect physics objects but are not themselves affected by physics (Half Life Alyx has detaching hands when they intersect physics objects but that sounds really hard!)
2 Likes

It seems to happen, when the Terrain has a RigidBody and Bullet’s debug is enabled.

public final class VrExample {
	
	public static void main(final String[] args) {
		final AppSettings settings = new AppSettings(true);
		settings.put("Renderer", AppSettings.LWJGL_OPENGL45);
		settings.setTitle("Tamarin OpenXR Example");
		settings.setSamples(4);
		settings.setWindowSize(1280, 720);
		settings.setVSync(false);
		
		final SimpleApplication app = new SimpleApplication(
				new DesktopSimulatingXrAppState(),
				new DesktopSimulatingXrActionAppState(manifest(), ActionHandles.HAND_POSE, ActionSets.MAIN),
				new VRHandsAppState(handSpec()),
				
				new FlyCamAppState(),
				new StatsAppState(),
				new ConstantVerifierState(),
				new AudioListenerState(),
				new DebugKeysAppState()) {
			
			@Override
			public void simpleInitApp() {
				
			}
			
			@Override
			public void simpleUpdate(final float tpf) {
				super.simpleUpdate(tpf);
				//System.out.println();
			}
		};
		
		app.setLostFocusBehavior(LostFocusBehavior.Disabled);
		app.setSettings(settings);
		app.setShowSettings(false);
		app.start();
		
		app.getStateManager().attach(new BaseAppState() {
			
			@Override
			protected void onEnable() {
				
			}
			
			@Override
			protected void onDisable() {
				
			}
			
			@Override
			protected void initialize(final Application app) {
				initApplication((SimpleApplication) app);
				getStateManager().detach(this);
			}
			
			@Override
			protected void cleanup(final Application app) {
				
			}
		});
	}
	
	private static void initApplication(final SimpleApplication app) {
		final BulletAppState state = new BulletAppState();
		state.setDebugEnabled(true);
		app.getStateManager().attach(state);
		
		createTerrain(app);
		
		final VRHandsAppState handState = app.getStateManager().getState(VRHandsAppState.ID, VRHandsAppState.class);
		final List <BoundHand> hands = handState.getHandControls();
		hands.forEach(new Consumer<BoundHand>() {
			
			@Override
			public void accept(final BoundHand boundHand) {
				boundHand.setGrabAction(VrActionHandles.GRIP, app.getRootNode());
			}
			
		});
	}
	
	private static void createTerrain(final SimpleApplication app) {
		final AssetManager asset = app.getAssetManager();
		
		final Material mat = new Material(asset, "Common/MatDefs/Terrain/Terrain.j3md");
		mat.setTexture("Alpha", asset.loadTexture("Textures/Terrain/splat/alphamap.png"));
		
		final Texture grass = asset.loadTexture("Textures/Terrain/splat/grass.jpg");
		grass.setWrap(WrapMode.Repeat);
		mat.setTexture("Tex1", grass);
		mat.setFloat("Tex1Scale", 64f);
		
		final Texture dirt = asset.loadTexture("Textures/Terrain/splat/dirt.jpg");
		dirt.setWrap(WrapMode.Repeat);
		mat.setTexture("Tex2", dirt);
		mat.setFloat("Tex2Scale", 32f);
		
		final Texture rock = asset.loadTexture("Textures/Terrain/splat/road.jpg");
		rock.setWrap(WrapMode.Repeat);
		mat.setTexture("Tex3", rock);
		mat.setFloat("Tex3Scale", 128f);
		
		final TerrainQuad terrain = new TerrainQuad("", 65, 513, new float[263169]);
		
		terrain.setMaterial(mat);
		terrain.setLocalTranslation(0, 0, 0);
		terrain.setLocalScale(2f, 1f, 2f);
		app.getRootNode().attachChild(terrain);
		
		final CollisionShape shape = CollisionShapeFactory.createMeshShape(terrain);
		final RigidBodyControl rigid = new RigidBodyControl(shape, 0f);
		terrain.addControl(rigid);
		app.getStateManager().getState(BulletAppState.class).getPhysicsSpace().add(rigid);
	}
	
	public static ActionManifest manifest(){
		final Action grip = Action.builder()
				.actionHandle(VrActionHandles.GRIP)
				.translatedName("Grip an item")
				.actionType(ActionType.FLOAT)
				.withSuggestedBinding(GoogleDaydreamController.PROFILE, GoogleDaydreamController.pathBuilder().leftHand().trackpadClick())
				.withSuggestedBinding(GoogleDaydreamController.PROFILE, GoogleDaydreamController.pathBuilder().rightHand().trackpadClick())
				.withSuggestedBinding(HtcViveController.PROFILE, HtcViveController.pathBuilder().leftHand().squeezeClick())
				.withSuggestedBinding(HtcViveController.PROFILE, HtcViveController.pathBuilder().rightHand().squeezeClick())
				.withSuggestedBinding(MixedRealityMotionController.PROFILE, MixedRealityMotionController.pathBuilder().leftHand().squeezeClick())
				.withSuggestedBinding(MixedRealityMotionController.PROFILE, MixedRealityMotionController.pathBuilder().rightHand().squeezeClick())
				.withSuggestedBinding(OculusGoController.PROFILE, OculusGoController.pathBuilder().leftHand().trackpadX())
				.withSuggestedBinding(OculusGoController.PROFILE, OculusGoController.pathBuilder().rightHand().trackpadX())
				.withSuggestedBinding(OculusTouchController.PROFILE, OculusTouchController.pathBuilder().leftHand().squeeze())
				.withSuggestedBinding(OculusTouchController.PROFILE, OculusTouchController.pathBuilder().rightHand().squeeze())
				.withSuggestedBinding(ValveIndexController.PROFILE, ValveIndexController.pathBuilder().leftHand().squeezeValue())
				.withSuggestedBinding(ValveIndexController.PROFILE, ValveIndexController.pathBuilder().rightHand().squeezeValue())
				.withDesktopSimulationKeyTrigger(HandSide.LEFT, new KeyTrigger(KeyInput.KEY_F1), true)
				.withDesktopSimulationKeyTrigger(HandSide.RIGHT, new KeyTrigger(KeyInput.KEY_F2), true)
				.build();
		
		final Action haptic = Action.builder()
				.actionHandle(VrActionHandles.HAPTIC)
				.translatedName("Haptic feedback")
				.actionType(ActionType.HAPTIC)
				.withSuggestAllKnownHapticBindings()
				.build();
		
		final Action trigger = Action.builder()
				.actionHandle(VrActionHandles.TRIGGER)
				.translatedName("Trigger action")
				.actionType(ActionType.FLOAT)
				.withSuggestedBinding(GoogleDaydreamController.PROFILE, GoogleDaydreamController.pathBuilder().leftHand().selectClick())
				.withSuggestedBinding(GoogleDaydreamController.PROFILE, GoogleDaydreamController.pathBuilder().rightHand().selectClick())
				.withSuggestedBinding(HtcViveController.PROFILE, HtcViveController.pathBuilder().leftHand().triggerValue())
				.withSuggestedBinding(HtcViveController.PROFILE, HtcViveController.pathBuilder().rightHand().triggerValue())
				.withSuggestedBinding(MixedRealityMotionController.PROFILE, MixedRealityMotionController.pathBuilder().leftHand().triggerValue())
				.withSuggestedBinding(MixedRealityMotionController.PROFILE, MixedRealityMotionController.pathBuilder().rightHand().triggerValue())
				.withSuggestedBinding(OculusGoController.PROFILE, OculusGoController.pathBuilder().leftHand().triggerClick())
				.withSuggestedBinding(OculusGoController.PROFILE, OculusGoController.pathBuilder().rightHand().triggerClick())
				.withSuggestedBinding(OculusTouchController.PROFILE, OculusTouchController.pathBuilder().leftHand().triggerValue())
				.withSuggestedBinding(OculusTouchController.PROFILE, OculusTouchController.pathBuilder().rightHand().triggerValue())
				.withSuggestedBinding(ValveIndexController.PROFILE, ValveIndexController.pathBuilder().leftHand().triggerValue())
				.withSuggestedBinding(ValveIndexController.PROFILE, ValveIndexController.pathBuilder().rightHand().triggerValue())
				.withDesktopSimulationKeyTrigger(HandSide.LEFT, new KeyTrigger(KeyInput.KEY_F3), false)
				.withDesktopSimulationKeyTrigger(HandSide.RIGHT, new KeyTrigger(KeyInput.KEY_F4), false)
				.build();

		final Action handPose = Action.builder()
				.actionHandle(VrActionHandles.HAND_POSE)
				.translatedName("Hand Pose")
				.actionType(ActionType.POSE)
				.withSuggestAllKnownAimPoseBindings()
				.build();
		
		
		/*
		 * This puts all the dpad controls into a single action, this sucks but is the only way to do it on older runtimes
		 * which may not support the XR_EXT_dpad_binding extension
		 */
		final Action movementDPad = Action.builder()
				.actionHandle(VrActionHandles.MOVEMENT_DPAD)
				.translatedName("Step Movement controls")
				.actionType(ActionType.VECTOR2F)
				.withSuggestedBinding(GoogleDaydreamController.PROFILE, GoogleDaydreamController.pathBuilder().rightHand().trackpad())
				.withSuggestedBinding(HtcViveController.PROFILE, HtcViveController.pathBuilder().rightHand().trackpad())
				.withSuggestedBinding(MixedRealityMotionController.PROFILE, MixedRealityMotionController.pathBuilder().rightHand().trackpad())
				.withSuggestedBinding(OculusGoController.PROFILE, OculusGoController.pathBuilder().rightHand().trackpad())
				.withSuggestedBinding(OculusTouchController.PROFILE, OculusTouchController.pathBuilder().rightHand().thumbStick())
				.withSuggestedBinding(ValveIndexController.PROFILE, ValveIndexController.pathBuilder().rightHand().thumbStick())
				.build();
		
		final Action walk = Action.builder()
				.actionHandle(VrActionHandles.WALK)
				.translatedName("Walk")
				.actionType(ActionType.VECTOR2F)
				.withSuggestedBinding(GoogleDaydreamController.PROFILE, GoogleDaydreamController.pathBuilder().leftHand().trackpad())
				.withSuggestedBinding(HtcViveController.PROFILE, HtcViveController.pathBuilder().leftHand().trackpad())
				.withSuggestedBinding(MixedRealityMotionController.PROFILE, MixedRealityMotionController.pathBuilder().leftHand().trackpad())
				.withSuggestedBinding(OculusGoController.PROFILE, OculusGoController.pathBuilder().leftHand().trackpad())
				.withSuggestedBinding(OculusTouchController.PROFILE, OculusTouchController.pathBuilder().leftHand().thumbStick())
				.withSuggestedBinding(ValveIndexController.PROFILE, ValveIndexController.pathBuilder().leftHand().thumbStick())
				.build();
		
		final Action openHandMenu = Action.builder()
				.actionHandle(VrActionHandles.OPEN_HAND_MENU)
				.translatedName("Open Hand Menu")
				.actionType(ActionType.BOOLEAN)
				.withSuggestedBinding(GoogleDaydreamController.PROFILE, GoogleDaydreamController.pathBuilder().leftHand().trackpadClick())
				.withSuggestedBinding(GoogleDaydreamController.PROFILE, GoogleDaydreamController.pathBuilder().rightHand().trackpadClick())
				.withSuggestedBinding(HtcViveController.PROFILE, HtcViveController.pathBuilder().leftHand().trackpadClick())
				.withSuggestedBinding(HtcViveController.PROFILE, HtcViveController.pathBuilder().rightHand().trackpadClick())
				.withSuggestedBinding(MixedRealityMotionController.PROFILE, MixedRealityMotionController.pathBuilder().leftHand().trackpadClick())
				.withSuggestedBinding(MixedRealityMotionController.PROFILE, MixedRealityMotionController.pathBuilder().rightHand().trackpadClick())
				.withSuggestedBinding(OculusGoController.PROFILE, OculusGoController.pathBuilder().leftHand().trackpadClick())
				.withSuggestedBinding(OculusGoController.PROFILE, OculusGoController.pathBuilder().rightHand().trackpadClick())
				.withSuggestedBinding(OculusTouchController.PROFILE, OculusTouchController.pathBuilder().leftHand().thumbStickClick())
				.withSuggestedBinding(OculusTouchController.PROFILE, OculusTouchController.pathBuilder().rightHand().thumbStickClick())
				.withSuggestedBinding(OculusTouchController.PROFILE, OculusTouchController.pathBuilder().rightHand().aClick())
				.withSuggestedBinding(OculusTouchController.PROFILE, OculusTouchController.pathBuilder().rightHand().aTouch())
				.withSuggestedBinding(ValveIndexController.PROFILE, ValveIndexController.pathBuilder().leftHand().thumbStickClick())
				.withSuggestedBinding(ValveIndexController.PROFILE, ValveIndexController.pathBuilder().rightHand().thumbStickClick())
				.withDesktopSimulationKeyTrigger(HandSide.LEFT, new KeyTrigger(KeyInput.KEY_F5), true)
				.withDesktopSimulationKeyTrigger(HandSide.RIGHT, new KeyTrigger(KeyInput.KEY_F6), true)
				.build();
		
		return ActionManifest.builder()
				.withActionSet(ActionSet
						.builder()
						.name("main")
						.translatedName("Main Actions")
						.priority(1)
						.withAction(grip)
						.withAction(haptic)
						.withAction(trigger)
						.withAction(handPose)
						.withAction(movementDPad)
						.withAction(walk)
						.withAction(openHandMenu)
						.build()
						).build();
	}
	
	private static HandSpec handSpec(){
		return HandSpec.builder(
				VrActionHandles.HAND_POSE,
				VrActionHandles.HAND_POSE)
			.applyMaterialToLeftHand((hand, assetManager) -> {
				//use the standard Tamarin texture but use a lit material instead
				final Material mat = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
				mat.setTexture("DiffuseMap", assetManager.loadTexture("Tamarin/Textures/basicHands_pinStripe.png"));
				hand.setMaterial(mat);
			})
			.applyMaterialToRightHand((hand, assetManager) -> {
				//use the standard Tamarin texture but use a lit material instead
				final Material mat = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
				mat.setTexture("DiffuseMap", assetManager.loadTexture("Tamarin/Textures/basicHands_pinStripe.png"));
				hand.setMaterial(mat);
			})
			.build();
	}
	
	private static final class ActionHandles {
		
		public static final ActionHandle GRIP = new ActionHandle(ActionSets.MAIN, "grip");
		public static final ActionHandle HAPTIC = new ActionHandle(ActionSets.MAIN, "haptic");
		public static final ActionHandle TRIGGER = new ActionHandle(ActionSets.MAIN, "trigger");
		public static final ActionHandle HAND_POSE = new ActionHandle(ActionSets.MAIN, "handpose");
		
		/**
		 * This puts all the dpad controls into a single action, this sucks but is the only way to do it on older runtimes
		 * which may not support the XR_EXT_dpad_binding extension
		 */
		public static final ActionHandle MOVEMENT_DPAD = new ActionHandle(ActionSets.MAIN, "movement_dpad");
		
		public static final ActionHandle WALK = new ActionHandle(ActionSets.MAIN, "walk");
		public static final ActionHandle OPEN_HAND_MENU = new ActionHandle(ActionSets.MAIN, "open_hand_menu");
	}
	
	private static final class ActionSets {
		
		public static String MAIN = "main";
		
	}
	
}

Strange. I just linked with the Meta Quest Link, shown my desktop, pressed in the SDK long (=right click) on “Main.java” and clicked “Run File”. Tested also with starting SteamVR first.

Thank you very much for addressing that.

By the way: Main.java doesn’t set vsync to false; MainVrSimulationMode sets vsync to false but doesn’t use a real vr headset. ^^

@sgold I have something working that integrates Tamarin and Minie, but I’m not that familiar with Minie so I’d appreciate a second opinion, I’ve got some specific concerns at the end of each section. This has been my approach:

Occlude the view if the player’s head is detected inside a physics object

Create a PhysicsGhostObject to detect head-in-wall situations

The headGhostSphere will be the physics object I use to test if the players head is inside a wall or similar.

    private CollisionShape headCollisionShape = new SphereCollisionShape(0.3f);
    private PhysicsGhostObject headGhostSphere = new PhysicsGhostObject(headCollisionShape);
    private float headObjectPenetration_last;
    private float headObjectPenetration;
    @Override
    protected void initialize(Application app){
          ....
        physicsSpace.add(headGhostSphere);

        physicsSpace.addTickListener(new PhysicsTickListener(){
            @Override
            public void prePhysicsTick(PhysicsSpace space, float timeStep){
                headObjectPenetration_last = headObjectPenetration;
                headObjectPenetration = 0;
            }

            @Override
            public void physicsTick(PhysicsSpace space, float timeStep){}
        });
        
        physicsSpace.addOngoingCollisionListener(event -> {
            if (event.getObjectA() == headGhostSphere || event.getObjectB() == headGhostSphere){
                headObjectPenetration = Math.max(headObjectPenetration, Math.abs(event.getDistance1()));
            }
        });
    }

Here I’m recording the last physics ticks head-object penetration. I’m using the last tick’s to make sure I don’t splice frames and get flicker. I do feel like I’m doing some weird cross thread stuff though, is there a better way to get a physics headObjectPenetration reading available in the JME thread

Use the headObjectPenetration to vignette the view

Where the head is in a wall reduce the players vision (eventually to nothing if their head is really in the wall)

    private static final float TOTAL_OCCLUSION_PENETRATION_DEPTH = 0.10f;
    private  VrVignetteState vignette = new VrVignetteState();
    @Override
    protected void initialize(Application app){
        getStateManager().attach(vignette);
    }
    @Override
    public void update(float tpf){
        // position head for next physics frame
        Vector3f headPosition = vrAppState.getVrCameraPosition();
        headGhostSphere.setPhysicsLocation(headPosition);
        
        // vignette if previous physics frame found our head near/in a wall
        vignette.setVignetteAmount(Math.clamp(headObjectPenetration_last / TOTAL_OCCLUSION_PENETRATION_DEPTH, 0, 1));

    }

This causes this sort of effect as your head gets near a wall

If you get very close your view is totally occluded.

[N.B VrVignetteState is something I just created, it isn’t yet released in Tamarin but will be in the next version]

Use sweep tests to veto player movement

When a player requests motion via their thumb stick use a sweep test using a sphere at knee height. If that sweep tests hits anything veto the movement. It is at knee height to allow steps to be navigated

    public void moveViaControls(float timeslice){

        Vector2fActionState analogActionState = openXrActionState.getVector2fActionState(ActionHandles.WALK);
        //we'll want the joystick to move the player relative to the head face direction, not the hand pointing direction
        Vector3f walkingDirectionRaw = new Vector3f(-analogActionState.getX(), 0, analogActionState.getY());

        Quaternion lookDirection = new Quaternion().lookAt(vrAppState.getVrCameraLookDirection(), Vector3f.UNIT_Y);

        Vector3f playerRelativeWalkDirection = lookDirection.mult(walkingDirectionRaw);
        playerRelativeWalkDirection.y = 0;
        if (playerRelativeWalkDirection.length()>0){
            playerRelativeWalkDirection.normalizeLocal();

            float sizeOfFootTest = 0.3f;
            ConvexShape footTestShape = new SphereCollisionShape(sizeOfFootTest);

            Vector3f startingFootPosition = getPlayerFeetPosition().add(0, MAXIMUM_ALLOWED_STEP_HEIGHT + sizeOfFootTest, 0);
            Vector3f endingFootPosition = startingFootPosition.add(playerRelativeWalkDirection.mult(2f * timeslice));

            Transform startingFootTransform = new Transform();
            startingFootTransform.setTranslation(startingFootPosition);

            Transform endingFootTransform = new Transform();
            endingFootTransform.setTranslation(endingFootPosition);

            List<PhysicsSweepTestResult> results = physicsSpace.sweepTest(footTestShape, startingFootTransform, endingFootTransform);
            if(results.isEmpty()){
                // allow the motion (move the section of the physical world that overlaps the Virtual world)
               getObserver().setLocalTranslation(getObserver().getWorldTranslation().add(playerRelativeWalkDirection.mult(2f * timeslice)));
            }
        }
    }

Is it ok for me to be interrogating the physics space within the JME thread like this?

Use rays tests to detect falling or steps

During (and only during) joystick motion check how close the floor is to the player. If it is too close then treat that as having moved onto a step and move the origin so the player moves up. If the floor is too far away treat that as detecting a fall condition

    private boolean playerIsFalling = false;
    public void moveViaControls(float timeslice){

        // as before
        if (playerRelativeWalkDirection.length()>0){
            // as before
            if(results.isEmpty()){
                // as before

                // see if we should now "step up" as a result of an incline or fall
                float totalTestLineLength = sizeOfFootTest + MAXIMUM_ALLOWED_STEP_HEIGHT + FALL_CHECK_STEP_HEIGHT;
                float bottomOfFootTestLineLength = sizeOfFootTest + MAXIMUM_ALLOWED_STEP_HEIGHT;

                List<PhysicsRayTestResult> physicsRayTestResults = physicsSpace.rayTest(endingFootPosition, endingFootPosition.add(0, -totalTestLineLength, 0));

                if(physicsRayTestResults.isEmpty()){
                    // unsupported, player starts falling
                    playerIsFalling = true;
                } else{
                    // see if we should "step up"
                    float furthestPointFraction = Float.MAX_VALUE;
                    for(PhysicsRayTestResult rayTestResult : physicsRayTestResults){
                        furthestPointFraction = Math.min(furthestPointFraction, rayTestResult.getHitFraction());
                    }
                    float furthestPointLength = furthestPointFraction * totalTestLineLength;
                    if(furthestPointLength < bottomOfFootTestLineLength){
                        float stepUp = bottomOfFootTestLineLength - furthestPointLength;
                        getObserver().setLocalTranslation(getObserver().getWorldTranslation().add(0, stepUp, 0));
                    }
                }
            }
        }
    }

Calculate falling myself (outside the Physics engine)

While falling constantly scan for the ground and move the player downwards until we get there (aka falling).

    private static final float PLAYER_FALL_SPEED = 10f;
    @Override
    public void update(float tpf){
        if(playerIsFalling){
             Vector3f playerFootPosition = getPlayerFeetPosition();
    
            float distanceToTest = 1;
    
            List<PhysicsRayTestResult> physicsRayTestResults = physicsSpace.rayTest(playerFootPosition, playerFootPosition.add(0, -distanceToTest, 0));
    
            float fractionToGround = Float.MAX_VALUE;
            for(PhysicsRayTestResult rayTestResult : physicsRayTestResults){
                fractionToGround = Math.min(fractionToGround, rayTestResult.getHitFraction());
            }
            float distanceToGround = fractionToGround * distanceToTest;
    
            float distanceToFall = tpf * PLAYER_FALL_SPEED;
            if(distanceToFall>distanceToGround){
                playerIsFalling = false;
                distanceToFall = distanceToGround;
            }
    
            getObserver().setLocalTranslation(getObserver().getWorldTranslation().add(0, -distanceToFall, 0));
        }
    }

I have similar concerns here about calling physicsSpace methods from the JME loop.

Thoughts / Justification

An immediate thought might be why not use a CapsuleCollisionShape for the player and update the camera position based on the physics. I do have a working application running that way, but it doesn’t behave well with “leaning over edges/tables” because the physics engine thinks you are stepping off the edge/onto the table because the CapsuleCollisionShape follows the camera. Similarly I can’t do the vision occlusion when a player puts their head in a wall, instead the world is “bounced away” which is horrible. By being a bit more DIY I can be a lot more selective of which bits of physics affect the player in which ways.

All this stuff about the observer in the code is changing the offset between the real world and the virtual world; a VR application can never move the players head directly (because the player’s real head controls that) only the offset between the real and virtual worlds to give the impression of head movement.

The headGhostSphere should probably only measure things like walls (kinematic?), you don’t want your view occluded just because a bullet was too near you. Is there a good way to mark objects so my test can include/exclude them?

If I get something nice I think I’ll try to bundle it up within Tamarin that can just be plugged in rather than it being a complicated example people have to copy into their own project.

Complete example

Here is a full example of an app state that does this (I think all of the important stuff is above, but just to show it all together)

public class PhysicsVetoingPhysicsExampleState extends BaseAppState{
    private static final float TOTAL_OCCLUSION_PENETRATION_DEPTH = 0.10f;

    private static final float MAXIMUM_ALLOWED_STEP_HEIGHT = 0.25f;

    private static final float FALL_CHECK_STEP_HEIGHT = 0.01f;

    private static final float PLAYER_FALL_SPEED = 10f;

    Node rootNodeDelegate = new Node("BlockMovingExampleState");
    XrAppState vrAppState;
    XrActionAppState openXrActionState;

    BulletAppState bulletAppState;

    List<FunctionRegistration> functionRegistrations = new ArrayList<>();

    VrVignetteState vignette = new VrVignetteState();

    CollisionShape headCollisionShape = new SphereCollisionShape(0.3f);
    PhysicsGhostObject headGhostSphere = new PhysicsGhostObject(headCollisionShape);

    float headObjectPenetration_last = 0;
    float headObjectPenetration = 0;

    PhysicsSpace physicsSpace;

    boolean playerIsFalling = false;

    @Override
    protected void initialize(Application app){
        ((SimpleApplication)app).getRootNode().attachChild(rootNodeDelegate);
        vrAppState = getState(XrAppState.ID, XrAppState.class);
        openXrActionState = getState(XrActionAppState.ID, XrActionAppState.class);
        vignette.setVignetteAmount(0);

        getStateManager().attach(vignette);

        bulletAppState = new BulletAppState();
        getStateManager().attach(bulletAppState);
        initialiseScene();

    }

    @Override
    protected void cleanup(Application app){
        rootNodeDelegate.removeFromParent();
        functionRegistrations.forEach(FunctionRegistration::endFunction);
        getStateManager().detach(vignette);
        getStateManager().detach(bulletAppState);
    }

    @Override
    protected void onEnable(){}

    @Override
    protected void onDisable(){}

    private void initialiseScene(){
        physicsSpace = bulletAppState.getPhysicsSpace();
        physicsSpace.setMaxTimeStep(1f/90);

        rootNodeDelegate.attachChild(checkerboardFloor(getApplication().getAssetManager(), physicsSpace));
        
        wall(new Vector3f(-1, 5, 10), new Vector3f(0.1f, 5, 0.5f), physicsSpace);

        physicsSpace.add(headGhostSphere);

        physicsSpace.addTickListener(new PhysicsTickListener(){
            @Override
            public void prePhysicsTick(PhysicsSpace space, float timeStep){
                headObjectPenetration_last = headObjectPenetration;
                headObjectPenetration = 0;
            }

            @Override
            public void physicsTick(PhysicsSpace space, float timeStep){

            }
        });

        physicsSpace.addOngoingCollisionListener(event -> {
            if (event.getObjectA() == headGhostSphere || event.getObjectB() == headGhostSphere){
                System.out.println("Collision " + event.getDistance1());
                headObjectPenetration = Math.max(headObjectPenetration, Math.abs(event.getDistance1()));
            }
        });

        //lastTickPhysicsFeetPosition = playerControl.getPhysicsLocation().subtract(0, playersTrueCapsuleHeight/2,0);

        //add some stairs to walk up
        step(new Vector3f(-1,0, 5), new Vector3f(1,0.2f, 8), ColorRGBA.Red, physicsSpace);
        step(new Vector3f(-1,0.2f, 5), new Vector3f(1,0.4f, 7.5f), ColorRGBA.Blue, physicsSpace);
        step(new Vector3f(-1,0.4f, 5), new Vector3f(1,0.6f, 7), ColorRGBA.Green, physicsSpace);
        step(new Vector3f(-1,0.6f, 5), new Vector3f(1,0.8f, 6.5f), ColorRGBA.Red, physicsSpace);
        step(new Vector3f(-1,0.8f, 5), new Vector3f(1,1f, 6.0f), ColorRGBA.Blue, physicsSpace);
        step(new Vector3f(-1,1f, 5), new Vector3f(1,1.2f, 5.5f), ColorRGBA.Green, physicsSpace);
        

    }

    @Override
    public void update(float tpf){
        super.update(tpf);

        Vector3f headPosition = vrAppState.getVrCameraPosition();

        moveViaControls(tpf);

        headGhostSphere.setPhysicsLocation(headPosition);

        vignette.setVignetteAmount(Math.clamp(headObjectPenetration_last/TOTAL_OCCLUSION_PENETRATION_DEPTH, 0, 1));

        if(playerIsFalling){
            fall(tpf);
        }
    }

    public void moveViaControls(float timeslice){

        Vector2fActionState analogActionState = openXrActionState.getVector2fActionState(ActionHandles.WALK);
        //we'll want the joystick to move the player relative to the head face direction, not the hand pointing direction
        Vector3f walkingDirectionRaw = new Vector3f(-analogActionState.getX(), 0, analogActionState.getY());

        Quaternion lookDirection = new Quaternion().lookAt(vrAppState.getVrCameraLookDirection(), Vector3f.UNIT_Y);

        Vector3f playerRelativeWalkDirection = lookDirection.mult(walkingDirectionRaw);
        playerRelativeWalkDirection.y = 0;
        if (playerRelativeWalkDirection.length()>0){
            playerRelativeWalkDirection.normalizeLocal();

            float sizeOfFootTest = 0.3f;
            ConvexShape footTestShape = new SphereCollisionShape(sizeOfFootTest);

            Vector3f startingFootPosition = getPlayerFeetPosition().add(0, MAXIMUM_ALLOWED_STEP_HEIGHT + sizeOfFootTest, 0);
            Vector3f endingFootPosition = startingFootPosition.add(playerRelativeWalkDirection.mult(2f * timeslice));

            Transform startingFootTransform = new Transform();
            startingFootTransform.setTranslation(startingFootPosition);

            Transform endingFootTransform = new Transform();
            endingFootTransform.setTranslation(endingFootPosition);

            List<PhysicsSweepTestResult> results = physicsSpace.sweepTest(footTestShape, startingFootTransform, endingFootTransform);
            if(results.isEmpty()){
                // allow the motion
                getObserver().setLocalTranslation(getObserver().getWorldTranslation().add(playerRelativeWalkDirection.mult(2f * timeslice)));

                // see if we should now "step up" as a result of an incline or fall
                float totalTestLineLength = sizeOfFootTest + MAXIMUM_ALLOWED_STEP_HEIGHT + FALL_CHECK_STEP_HEIGHT;
                float bottomOfFootTestLineLength = sizeOfFootTest + MAXIMUM_ALLOWED_STEP_HEIGHT;

                List<PhysicsRayTestResult> physicsRayTestResults = physicsSpace.rayTest(endingFootPosition, endingFootPosition.add(0, -totalTestLineLength, 0));

                if(physicsRayTestResults.isEmpty()){
                    // unsupported, player starts falling
                    playerIsFalling = true;
                } else{
                    // see if we should "step up"
                    float furthestPointFraction = Float.MAX_VALUE;
                    for(PhysicsRayTestResult rayTestResult : physicsRayTestResults){
                        furthestPointFraction = Math.min(furthestPointFraction, rayTestResult.getHitFraction());
                    }
                    float furthestPointLength = furthestPointFraction * totalTestLineLength;
                    if(furthestPointLength < bottomOfFootTestLineLength){
                        float stepUp = bottomOfFootTestLineLength - furthestPointLength;
                        getObserver().setLocalTranslation(getObserver().getWorldTranslation().add(0, stepUp, 0));
                    }
                }
            }
        }
    }

    public void fall(float timeslice){
            Vector3f playerFootPosition = getPlayerFeetPosition();

            float distanceToTest = 1;

            List<PhysicsRayTestResult> physicsRayTestResults = physicsSpace.rayTest(playerFootPosition, playerFootPosition.add(0, -distanceToTest, 0));

            float fractionToGround = Float.MAX_VALUE;
            for(PhysicsRayTestResult rayTestResult : physicsRayTestResults){
                fractionToGround = Math.min(fractionToGround, rayTestResult.getHitFraction());
            }
            float distanceToGround = fractionToGround * distanceToTest;

            float distanceToFall = timeslice * PLAYER_FALL_SPEED;
            if(distanceToFall>distanceToGround){
                playerIsFalling = false;
                distanceToFall = distanceToGround;
            }

            getObserver().setLocalTranslation(getObserver().getWorldTranslation().add(0, -distanceToFall, 0));
    }


    public static Geometry checkerboardFloor(AssetManager assetManager, PhysicsSpace physicsSpace){
        Quad floorQuad = new Quad(10,10);
        Geometry floor = new Geometry("floor", floorQuad);
        Texture floorTexture = assetManager.loadTexture("Textures/checkerBoard.png");
        floorTexture.setMagFilter(Texture.MagFilter.Nearest);
        Material mat = new Material(assetManager,"Common/MatDefs/Misc/Unshaded.j3md");
        mat.setTexture("ColorMap", floorTexture);

        floor.setMaterial(mat);
        Quaternion floorRotate = new Quaternion();
        floorRotate.fromAngleAxis(-FastMath.HALF_PI, Vector3f.UNIT_X);
        floor.setLocalRotation(floorRotate);
        floor.setLocalTranslation(-5,0,15);

        RigidBodyControl rigidBodyControl = new RigidBodyControl(0);
        floor.addControl(rigidBodyControl);

        physicsSpace.addCollisionObject(rigidBodyControl);
        return floor;
    }

    private void wall(Vector3f locationCentre, Vector3f size, PhysicsSpace physicsSpace){
        Box box = new Box(size.x, size.y, size.z);
        Geometry boxGeometry = new Geometry("wall", box);
        Material boxMat = new Material(getApplication().getAssetManager(),"Common/MatDefs/Misc/Unshaded.j3md");
        boxMat.setTexture("ColorMap", getApplication().getAssetManager().loadTexture("Textures/backboard.png"));
        boxGeometry.setMaterial(boxMat);
        boxGeometry.setLocalTranslation(locationCentre);
        RigidBodyControl rigidBodyControl = new RigidBodyControl(0);
        boxGeometry.addControl(rigidBodyControl);
        physicsSpace.addCollisionObject(rigidBodyControl);
        rootNodeDelegate.attachChild(boxGeometry);
    }

    private void step(Vector3f min, Vector3f max, ColorRGBA colour, PhysicsSpace physicsSpace){
        Vector3f size = max.subtract(min);
        Box box = new Box(size.x/2, size.y/2, size.z/2);
        Geometry boxGeometry = new Geometry("physicsBox", box);
        boxGeometry.setLocalTranslation(min.add(max).multLocal(0.5f));
        Material boxMat = new Material(getApplication().getAssetManager(),"Common/MatDefs/Misc/Unshaded.j3md");
        boxMat.setColor("Color", colour);
        boxMat.setTexture("ColorMap", getApplication().getAssetManager().loadTexture("Textures/backboard.png"));

        boxGeometry.setMaterial(boxMat);

        RigidBodyControl rigidBodyControl = new RigidBodyControl(0);
        boxGeometry.addControl(rigidBodyControl);
        physicsSpace.addCollisionObject(rigidBodyControl);

        rootNodeDelegate.attachChild(boxGeometry);
    }

    /**
     * The players feet are at the height of the observer, but the x,z of the cameras
     * @return
     */
    private Vector3f getPlayerFeetPosition(){
        return vrAppState.getPlayerFeetPosition();
    }

    private Node getObserver(){
        return vrAppState.getObserver();
    }

}
2 Likes

This may be the root of all issues.

I’d expect for a VR application especially, that keeping eye position separate from feet position (or center of gravity position) is important.

It also lets you decide to do things special when the player moves their own head around in the space but their ‘avatar’ cannot physically go there. Whereas simply preventing the movement will make people sick really fast. (At least until we have haptic feedback neck braces and things… but then I think we will create entirely new problems… lol)

2 Likes

Occlude the view if the player’s head is detected inside a physics object

Seems to me a strange feature to worry about. Surely you’d be better off preventing the player’s head from intersecting solid objects in the first place. Assuming those solid objects are opaque, set the near plane of the view frustum very short, and you get cheap occlusion.

If I actually needed to detect intersection between the player’s head and other objects, I might use a contact test (for slow-moving head and objects) or a sweep test (for fast-moving head and/or objects).

When a player requests motion via their thumb stick use a sweep test using a sphere at knee height.

That’s clever.

Is it ok for me to be interrogating the physics space within the JME thread like this?

I suspect Bullet might crash if the sweep test overlapped with a physics step. I’m unsure how big the risk actually is, though. Are use using ThreadingType.PARALLEL? If you’re using the default (ThreadingType.SEQUENTIAL) then physics steps are simulated on the render thread. If the sweep tests are all on the render thread, then I see no risk.

I have similar concerns here about calling physicsSpace methods from the JME loop.

Once again, if you do everything on the render thread, I see no risk.

a VR application can never move the players head directly (because the player’s real head controls that) only the offset between the real and virtual worlds to give the impression of head movement.

I’ve not tried combining VR with physics. I can see this would present a challenge.

Is there a good way to mark objects so my test can include/exclude them?

That seems like a good use for collision groups. My second choice would be to indicate include/excluded physics objects using setApplicationData().

That does sound like it would be the right thing to do. But the problem is nothing stops the player physically walking around in their room. If you stop the camera tracking that physical motion it doesn’t feel like they’ve been blocked by a physical object, it feels like the entire world has moved away from them. This is because they can feel where their feet are and feel that they lent forwards. That makes you immediately nauseous. Very few VR games do that, most use some sort of view occlusion (of which a vignette is the simplest). The one game I have seen that did do the “world moves to prevent intersection” had this said about it “It is so unpleasant that you quickly learn to keep your head away from walls”. I returned that game because while good in some ways it was too physically uncompromising to actually play.

My test is all SEQUENTIAL, but I want this to be in the library so I don’t want to enforce SEQUENTIAL unless it really is necessary. In PARALLEL I assume there is some sort of sync phase where all the physics positions are updated into the spatial positions?

Ooo, very cool, that was exactly what I was looking for. Thanks, I’ll give it a try

Yes, I agree with all of this. Thats why I have this DIY approach rather than a CapsuleCollisionShape. I was just talking about that to pre-empt “why didn’t you just use a Character Control” questions.

1 Like

Yeah, I’ve just found it useful even in non-VR games to separate “where the camera is” from “where the player is”. One usually follows the other but not always.

In this case, the player avatar is the physical object… the capsule shape, etc… This is where they are in the physical space. The “head” is a separate object that directly tracks with the real-life human’s head… it is only loosely ‘attached’ to the avatar/physical object.

1 Like

Looking at the Minie BulletAppState source I think SEQUENTIAL vs PARALLEL just affects if physics occurs in thread between render and postRender (with the future kicked off in render and collapsed in postRender, so forcing it to finish by then) or entirely within the render call. So in either case physics should be stable (i.e. not calculating a step) during appstate updates. Which makes me think what I’m doing is fine

1 Like

This is an omission, I’ll correct the example (nothing good will come from having vsync on in a VR application)

1 Like

@Nakano I’ve updated Tamarin to include more physics functionality and updated the example to use that functionality.

The new functionality includes:

Minie debug shapes can now be viewed in VR.

Can be activated by attaching the PhysicsDebugVrAppState. It mostly uses Minie’s own BulletDebugAppState (great job incidentally @sgold, was easy to integrate it with VR’s multiple viewports) but it handles setting up the overlay viewports and viewing it from both eyes’ perspectives

Here you can see the physics shapes overlaid on the graphics

Player movement can be vetoed by physics and physically moving your head into a wall vignettes

Falling and climbing happens when the player moves with the joystick (but never with their own legs)

Fingers can move objects, grab can grab objects

This bit definitely needs work, it feels very spongy moving things with your fingers and the grab leaves a lot to be desired. I originally tried to use a New6Dof to attach the held objects to the hand (or a kinematic object representing the hand) but the New6Dof behaved like a very weak spring (not really keeping the object in position) and producing fast spinning motions on rotating the hand. I’m sure I’m setting it up wrong but I’m unsure exactly how (the physics debug looks right). Or maybe New6Dof isn’t the right way to make a temporary hard link. This is how I set up the New6Dof

    private New6Dof attachDynamicToKinematic(PhysicsRigidBody kinematicBody, PhysicsRigidBody dynamicBody) {
        // Get world-space positions
        Vector3f posA = kinematicBody.getPhysicsLocation();
        Vector3f posB = dynamicBody.getPhysicsLocation();

        // Get world-space rotations
        Quaternion rotA = kinematicBody.getPhysicsRotation();
        Quaternion rotB = dynamicBody.getPhysicsRotation();

        Vector3f pivotPointWorld = posB;
        Quaternion pivotRotationWorld = rotB;

        // Compute pivot points in each body's local space
        Vector3f pivotInA = rotA.inverse().mult(pivotPointWorld.subtract(posA));
        Vector3f pivotInB = new Vector3f(0, 0, 0);

        Matrix3f rotInA = rotA.inverse().mult(pivotRotationWorld).toRotationMatrix(); // Convert world rotation to A's local space
        Matrix3f rotInB = Matrix3f.IDENTITY;

        New6Dof joint = new New6Dof(
                kinematicBody, dynamicBody,
                pivotInA, pivotInB,
                rotInA, rotInB,
                RotationOrder.XYZ
        );

        joint.setCollisionBetweenLinkedBodies(false);

        // Lock all axes
        for (int i = 0; i < 6; i++) {
            joint.set(MotorParam.LowerLimit, i, 0);
            joint.set(MotorParam.UpperLimit, i, 0);
        }

        physicsSpace.add(joint);

        return joint;
    }

And this is how it looks in debug

That box is being “held” by the hand, it is effected by the hand (and you can see the joint arrow in the debug) but the gravity of the box seems to pull it off the joint while is spins wildly.

As I couldn’t get New6Dof to work the current example just magically moves held object to where they ought to be. Which works fine as long as nothing gets in the way

Overall

It all feels very rough and ready. I think a real game would probably want to replace most of this but I hope it is a useful starting point. In particular the fingers get in the way of grabbing things. A real game would probably want to decide which was most important rather than my compromise

2 Likes