Hi,
i’m trying to do an interpolation system for my multiplayer games.
It is really simple and every advice is welcome
If you want to try to run this example copy this code:
GameClient:
[java]
package it.mygame.simpletest;
import com.jme3.app.SimpleApplication;
import com.jme3.asset.TextureKey;
import com.jme3.cinematic.events.MotionTrack;
import com.jme3.input.controls.ActionListener;
import com.jme3.input.controls.KeyTrigger;
import com.jme3.light.DirectionalLight;
import com.jme3.material.Material;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.network.Client;
import com.jme3.network.Message;
import com.jme3.network.MessageListener;
import com.jme3.network.Network;
import com.jme3.scene.Geometry;
import com.jme3.scene.shape.Box;
import com.jme3.system.AppSettings;
import com.jme3.texture.Texture;
import com.jme3.texture.Texture.WrapMode;
import java.io.IOException;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
-
@author Alessio
*/
public class GameClient extends SimpleApplication implements
MessageListener, ActionListener {
private Geometry localPlayer;
private Geometry interpolatedPlayer;
private Geometry serverPlayer;
private InterpolationControl interpolationControl;
private Client client;
private MotionTrack motionControl;
private long DELAY = 250L;
private long lastDelay = DELAY + (long) Math.random() * 100;
private List<UpdateMessage> messageQueue = new LinkedList<UpdateMessage>();
private boolean server = true;
private boolean local = true;
private boolean interpolated = true;
public static void main(String[] args) {
GameClient client = new GameClient();
AppSettings settings = new AppSettings(true);
settings.setFrameRate(-1);
client.setSettings(settings);
client.start();
}
@Override
public void simpleInitApp() {
GameServer.serializeAllMessages();
initFloorAndPlayers();
initKeys();
initNetwork();
cam.getLocation().setY(5);
interpolationControl = new InterpolationControl();
interpolationControl.setSpatial(interpolatedPlayer);
interpolatedPlayer.addControl(interpolationControl);
}
@Override
public void simpleUpdate(float tpf) {
// Check if there are messages that have to be processed
UpdateMessage um;
if (!messageQueue.isEmpty()) {
um = messageQueue.get(0);
} else {
um = null;
}
// Simulate network latency
if (um != null) {
if (System.currentTimeMillis() >= um.time + lastDelay) {
messageQueue.remove(0);
// If latency has passed update states
localPlayer.setLocalRotation(um.newState.rotation);
localPlayer.setLocalTranslation(um.newState.position);
// Update interpolation control
interpolationControl.addState(um.newState);
// Generate a delta latency to simulate network viaribility
lastDelay = DELAY + (long) Math.random() * 100;
}
}
}
public void messageReceived(Object source, Message m) {
if (m instanceof UpdateMessage) {
final UpdateMessage um = (UpdateMessage) m;
this.enqueue(new Callable<Void>(){
public Void call() throws Exception {
messageQueue.add(um);
// Update immediatly remote player to see his most up-to-date state
serverPlayer.setLocalRotation(um.newState.rotation);
serverPlayer.setLocalTranslation(um.newState.position);
return null;
}
});
}
}
private void initFloorAndPlayers() {
// Init floor
Box floor = new Box(Vector3f.ZERO, 10f, 0.1f, 5f);
floor.scaleTextureCoordinates(new Vector2f(3, 6));
Material floor_mat = new Material(assetManager,
"Common/MatDefs/Misc/Unshaded.j3md");
TextureKey key3 = new TextureKey("Textures/Terrain/Pond/Pond.jpg");
key3.setGenerateMips(true);
Texture tex3 = assetManager.loadTexture(key3);
tex3.setWrap(WrapMode.Repeat);
floor_mat.setTexture("ColorMap", tex3);
Geometry floor_geo = new Geometry("Floor", floor);
floor_geo.setMaterial(floor_mat);
floor_geo.setLocalTranslation(0, -0.1f, 0);
this.rootNode.attachChild(floor_geo);
// Init local non-interpolated player
Box b = new Box(Vector3f.ZERO, 0.5f, 0.5f, 0.5f);
localPlayer = new Geometry("Box", b);
Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", ColorRGBA.Blue);
localPlayer.setMaterial(mat);
localPlayer.getLocalTranslation().setY(0.5f);
rootNode.attachChild(localPlayer);
// init interpolated player
interpolatedPlayer = new Geometry("Box2", b);
Material mat2 = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat2.setColor("Color", ColorRGBA.Green);
interpolatedPlayer.setMaterial(mat2);
interpolatedPlayer.getLocalTranslation().setY(0.5f);
rootNode.attachChild(interpolatedPlayer);
// iniit remote player
serverPlayer = new Geometry("Box3", b);
Material mat3 = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat3.setColor("Color", ColorRGBA.Yellow);
serverPlayer.setMaterial(mat3);
serverPlayer.getLocalTranslation().setY(0.5f);
rootNode.attachChild(serverPlayer);
DirectionalLight light = new DirectionalLight();
light.setDirection(new Vector3f(0, -1, 0));
light.setColor(ColorRGBA.White);
rootNode.addLight(light);
}
private void initNetwork() {
try {
client = Network.connectToServer("127.0.0.1", 65530, 65531);
client.addMessageListener(this);
client.start();
} catch (IOException ex) {
Logger.getAnonymousLogger().log(Level.SEVERE, "It is not possible"
- " to connect the server due to {0}", ex.getMessage());
ex.printStackTrace();
System.exit(1);
}
}
@Override
public void destroy() {
client.close();
super.destroy();
}
private void initKeys() {
// Enable - disable displaying of remote player
inputManager.addMapping("server", new KeyTrigger(keyInput.KEY_P));
// Enable - disable displaying of local player
inputManager.addMapping("local", new KeyTrigger(keyInput.KEY_O));
// Enable - disable displaying of interpolated player
inputManager.addMapping("interpolated", new KeyTrigger(keyInput.KEY_I));
// Add - remove some delay
inputManager.addMapping("delay_up", new KeyTrigger(keyInput.KEY_L));
inputManager.addMapping("delay_down", new KeyTrigger(keyInput.KEY_K));
// Adjust interpolation factor
inputManager.addMapping("interp_factor_up", new KeyTrigger(keyInput.KEY_U));
inputManager.addMapping("interp_factor_down", new KeyTrigger(keyInput.KEY_Y));
inputManager.addListener(this,
"server",
"local",
"interpolated",
"delay_up",
"delay_down",
"interp_factor_up",
"interp_factor_down");
}
public void onAction(String name, boolean isPressed, float tpf) {
if (name.equals("server") && !isPressed) {
server = !server;
if (server) {
rootNode.attachChild(serverPlayer);
} else {
rootNode.detachChild(serverPlayer);
}
}
if (name.equals("local") && !isPressed) {
local = !local;
if (local) {
rootNode.attachChild(localPlayer);
} else {
rootNode.detachChild(localPlayer);
}
}
if (name.equals("interpolated") && !isPressed) {
interpolated = !interpolated;
if (interpolated) {
rootNode.attachChild(interpolatedPlayer);
} else {
rootNode.detachChild(interpolatedPlayer);
}
}
if (name.equals("delay_up") && !isPressed) {
DELAY += 25;
System.out.println("Min Delay:t" + DELAY);
}
if (name.equals("delay_down") && !isPressed) {
DELAY -= 25;
System.out.println("Min Delay:t" + DELAY);
}
if (name.equals("interp_factor_up") && !isPressed) {
interpolationControl.interpFactor += 5.0f;
System.out.println("interp factor:t" + interpolationControl.interpFactor);
}
if (name.equals("interp_factor_down") && !isPressed) {
interpolationControl.interpFactor -= 5.0f;
if (interpolationControl.interpFactor < 0){
interpolationControl.interpFactor = 0.0f;
}
System.out.println("interp factor:t" + interpolationControl.interpFactor);
}
}
}
[/java]
GameSerrver:
[java]
package it.mygame.simpletest;
import com.jme3.animation.LoopMode;
import com.jme3.app.SimpleApplication;
import com.jme3.cinematic.MotionPath;
import com.jme3.cinematic.events.MotionTrack;
import com.jme3.material.Material;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.network.ConnectionListener;
import com.jme3.network.HostedConnection;
import com.jme3.network.Message;
import com.jme3.network.MessageListener;
import com.jme3.network.Network;
import com.jme3.network.Server;
import com.jme3.network.serializing.Serializer;
import com.jme3.scene.Geometry;
import com.jme3.scene.shape.Box;
import com.jme3.system.JmeContext;
import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
*
-
@author Alessio
*/
public class GameServer extends SimpleApplication implements
ConnectionListener, MessageListener<HostedConnection> {
private boolean start = false;
private MotionTrack motionControl;
private float syncFreq = 1/5; // sync 10 times per second
private float dropRate = (float) 5/10; // 30 % packet loss
private float syncTime = 0.0f;
private Geometry newPlayer;
private Server server;
private static final Logger logger =
Logger.getLogger(GameServer.class.getName());
public static void main(String[] args) {
GameServer server = new GameServer();
server.start(JmeContext.Type.Headless);
}
@Override
public void simpleInitApp() {
serializeAllMessages();
initNetwork();
initPlayer();
// Create the track of the simulated remote player
MotionPath path = new MotionPath();
path.addWayPoint(new Vector3f(8, 0.5f, 0));
path.addWayPoint(new Vector3f(3, 0.5f, 5));
path.addWayPoint(new Vector3f(-5.0f, 0.5f, -5.0f));
path.addWayPoint(new Vector3f(-5.0f, 0.5f, 5));
path.addWayPoint(new Vector3f(5, 0.5f, -5.0f));
path.addWayPoint(new Vector3f(5, 0.5f, 0));
path.addWayPoint(new Vector3f(8, 0.5f, 0));
motionControl = new MotionTrack(newPlayer,path);
motionControl.setDirectionType(MotionTrack.Direction.PathAndRotation);
motionControl.setRotation(new Quaternion().fromAngleNormalAxis(-FastMath.HALF_PI, Vector3f.UNIT_Y));
motionControl.setInitialDuration(10f);
motionControl.setSpeed(1.5f);
motionControl.setLoopMode(LoopMode.Loop);
}
@Override
public void simpleUpdate(float tpf) {
if (start) {
syncTime += tpf;
if(syncTime >= syncFreq) {
// Simulate the packet loss
float x = FastMath.nextRandomFloat();
if (x < dropRate) {
State newState = new State(newPlayer.getLocalTranslation(),
newPlayer.getLocalRotation());
server.broadcast(new UpdateMessage(true, newState,
System.currentTimeMillis()));
}
syncTime = 0.0f;
}
}
}
public void messageReceived(HostedConnection source, Message m) {
}
private void initNetwork() {
try {
server = Network.createServer(65530, 65531);
server.addConnectionListener(this);
server.start();
logger.log(Level.ALL, "Server ready");
} catch (IOException ex) {
logger.log(Level.SEVERE, "It is not possible to start a network"
- " server due to {0}", ex.getMessage());
ex.printStackTrace();
System.exit(1);
}
}
private void initPlayer() {
// Inizializzo il giocatore
Box b = new Box(Vector3f.ZERO, 1, 1, 1);
newPlayer = new Geometry("player", b);
Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", ColorRGBA.Blue);
newPlayer.setMaterial(mat);
rootNode.attachChild(newPlayer);
}
public static void serializeAllMessages() {
Serializer.registerClass(UpdateMessage.class);
Serializer.registerClass(State.class);
}
public void connectionAdded(Server server, HostedConnection conn) {
start = true;
motionControl.play();
System.out.println("Client connected");
}
public void connectionRemoved(Server server, HostedConnection conn) {
}
}
[/java]
State:
[java]
package it.mygame.simpletest;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.network.serializing.Serializable;
/**
*
-
@author Alessio
*/
@Serializable
public class State {
public Vector3f position;
public Quaternion rotation;
public State() {}
public State(Vector3f position, Quaternion rotation) {
this.position = position;
this.rotation = rotation;
}
}
[/java]
UpdateMessage
[java]
package it.mygame.simpletest;
import com.jme3.network.AbstractMessage;
import com.jme3.network.serializing.Serializable;
/**
*
-
@author Alessio
*/
@Serializable
public class UpdateMessage extends AbstractMessage {
public long time;
public State newState;
public UpdateMessage(){}
public UpdateMessage(boolean tcp, State state, long time) {
super(tcp);
this.newState = state;
this.time = time;
}
}
[/java]
InterpolationControl:
[java]
package it.mygame.simpletest;
import com.jme3.export.JmeExporter;
import com.jme3.export.JmeImporter;
import com.jme3.math.FastMath;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
import com.jme3.scene.Spatial;
import com.jme3.scene.control.Control;
import java.io.IOException;
import java.util.LinkedList;
import java.util.List;
/**
*
-
@author Alessio
*/
public class InterpolationControl implements Control {
private List<State> intStateList =
new LinkedList<State>();
private Vector3f currentTranslation = Vector3f.ZERO;
private Vector3f targetTranslation = Vector3f.ZERO;
private Vector3f startTranslation = Vector3f.ZERO;
private Quaternion currentRotation = Quaternion.IDENTITY;
private Quaternion targetRotation = Quaternion.IDENTITY;
private Quaternion startRotation = Quaternion.IDENTITY;
private float currentScale = 0.0f;
public float interpFactor = 45.0f;
private Spatial player;
public boolean refresh = true;
public void update(float tpf) {
if (currentScale > 0.95f && intStateList.size() > 0) {
State nextTargetState = intStateList.remove(0);
// Set new target position
startTranslation = currentTranslation;
targetTranslation = nextTargetState.position;
// Set new target rotation
startRotation = currentRotation;
targetRotation = nextTargetState.rotation;
// reset the scale
currentScale = 0.0f;
}
// Update the scale
currentScale += tpf * interpFactor;
// Interpolate start position to target position
currentTranslation = FastMath.interpolateLinear(
currentScale,
startTranslation,
targetTranslation);
// Interpolate start rotation to target rotation
currentRotation.slerp(startRotation, targetRotation, currentScale);
player.setLocalTranslation(currentTranslation);
player.setLocalRotation(currentRotation);
}
public void addState(State newState) {
intStateList.add(newState);
}
//
//
public void setSpatial(Spatial spatial) {
player = spatial;
}
public Control cloneForSpatial(Spatial spatial) {
throw new UnsupportedOperationException("Not supported yet.");
}
public void setEnabled(boolean enabled) {
throw new UnsupportedOperationException("Not supported yet.");
}
public boolean isEnabled() {
throw new UnsupportedOperationException("Not supported yet.");
}
public void render(RenderManager rm, ViewPort vp) {
}
public void write(JmeExporter ex) throws IOException {
throw new UnsupportedOperationException("Not supported yet.");
}
public void read(JmeImporter im) throws IOException {
throw new UnsupportedOperationException("Not supported yet.");
}
}
[/java]
Some explainations:
On server a cube moves over a motion track in a loop and this movements are sampled and sent to client i with a frequency = GameServer.syncFreq. Because i run this example in a LAN i need to simulate packet loss with a GameServer.dropRate possibility.
On client is simulated a latency delaying the processing of the messages using a queue. At start this latency is set to deafult to 250 ms and varies of another 100 ms to simulate variability of network. For simplicity it is assumed that packets arrives in order (in UDP it is not guranteed but it is simple to check priority simply comparing the last message time and the arriving message. if the arriving message has a time lower than the last in the queue it is rejected)
On the client there are 3 entities:
The server player -> is the state of the player on the server side so the most up - to - date state.
The local player -> is the state where the delay is applied but is not interpolated
The interpolated player -> is the interpolated version of local client.
The InterpolationControl does all the job:
here there is a list that hold the target states that are followed by the interpolated player and when a state is reached the target state is updated to the next one.
There is also a variable that i call interpolation factor. I foud this variable perchance trying more and more to reach a more suitable visual result. In practice, for what i've seen, this is a rate that helps to smooth he cube in different forms:
In order to compensate the packat drop this value must be relatively low, (on my machine 30) and it results in a smooth move also when many packet are lost but the distance beetween the local player and interpolated player is greater.
In order to reduce this distance this factor can be setted to a value like 60 but if many packates are lost, it will swap (less than the local player) to the next position.
There are some inputs that help seeing the differences beetween some values:
P -> enable/disable server state rendering
O -> enable/disable local state rendering
I -> enable/disable interpolated state rendering
L -> increase base latency
K -> decrease base latency
U -> increase interpolation factor
Y -> decrease interpolation factor
i'm sure you have some of you can help me to improve this simple system :)
Thanks