@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();
}
}