Hey guys I've been beating my head against a trackball implementation issue using JOML and was hoping I could get some assistance in understanding my issue.
Before we take a look at my code let me explain my paradigm: my thought was that since I plan on adding several objects to my "scene" I want to rotate the "camera" about the origin of the scene using a trackball, as opposed to translating+rotating each object within the scene in order to achieve the same effect. Also, while I'm currently implementing the trackball functionality in Java using the CPU, I plan on migrating the associated computations into the GPU using the shaders once my understanding is firmed up. Let me know if this is the wrong way to go here!
I've created some sample code which should give a sense of what I'm trying to achieve. The code assumes SWT, LWJGL 3, and JOML without a fixed pipe. In the code I have two cubes which I've placed on either side of the yz-plane. One cube is stationary and centered on the x-axis, the other is spinning while also orbiting the x-axis. Using the trackball technique I'd like to be able to move the camera about my scene while continuing to look at the origin and maintaining a constant distance from the origin (I'll implement zoom, later). Further, I'd like for there to be no gimbal-lock.
I've been able to get really close to achieving this, but there is still some unexpected behavior that I'm having trouble tracking down. Specifically, when the user drags quickly the camera appears to get closer to the origin thus not meeting my "constant distance" criterion. I suspect the issue lies in my understanding of quaternions, which is elementary at best, but I may be doing something stupid. I'm hoping someone can spot the problem.
Here's a complete example:
TrackballTest Class
import static org.lwjgl.opengl.GL20.glGetUniformLocation;
import static org.lwjgl.opengl.GL20.glUniformMatrix4fv;
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.nio.FloatBuffer;
import org.eclipse.swt.SWT;
import org.eclipse.swt.SWTException;
import org.eclipse.swt.events.MouseEvent;
import org.eclipse.swt.events.MouseListener;
import org.eclipse.swt.events.MouseMoveListener;
import org.eclipse.swt.graphics.Color;
import org.eclipse.swt.layout.FillLayout;
import org.eclipse.swt.opengl.GLCanvas;
import org.eclipse.swt.opengl.GLData;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Shell;
import org.joml.AxisAngle4f;
import org.joml.Matrix3f;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import org.lwjgl.BufferUtils;
import org.lwjgl.opengl.GL;
import org.lwjgl.opengl.GL11;
import org.lwjgl.opengl.GL20;
import org.lwjgl.opengl.GLUtil;
import shape.Cube;
public class TrackballTest extends Composite
{
private static final String VERTEX_ATTRIBUTE_POSITION = "position";
private static final String VERTEX_ATTRIBUTE_COLOR = "color";
private static final String VERTEX_FIELD_MODEL = "model";
private static final String VERTEX_FIELD_VIEW = "view";
private static final String VERTEX_FIELD_PROJECTION = "projection";
// Canvas stuff
private GLCanvas canvas;
private float width, height;
// Shader stuff
private int vertexShaderID, fragmentShaderID, programID;
private int modelMatrixID, projectionMatrixID, viewMatrixID;
// Math stuff
private FloatBuffer buffer = BufferUtils.createFloatBuffer(16);
private Matrix4f projectionMatrix, viewMatrix;
private Vector3f eye, look, up, start, end;
private boolean isDragging = false;
// Model objects
private Cube cube1, cube2;
private double theta = 0;
public TrackballTest(Composite parent, int style)
{
super(parent, style);
this.setLayout(new FillLayout());
initializeCanvas();
initializeListeners();
initializeShaderProgram();
initializeScene();
// Initialize our objects
cube1 = new Cube();
cube2 = new Cube();
start();
}
private void initializeCanvas()
{
// Create GL data with double buffering
GLData data = new GLData();
data.doubleBuffer = true;
// Create the SWT context
canvas = new GLCanvas(this, SWT.NONE, data);
// Set the context as current
canvas.setCurrent();
GL.createCapabilities(true);
// Initialize the default clear color as SWT's default background color
Color bg = this.getDisplay().getSystemColor(SWT.COLOR_WIDGET_BACKGROUND);
GL11.glClearColor(bg.getRed() / (float) 255, bg.getGreen() / (float) 255,
bg.getBlue() / (float) 255, 1f);
bg.dispose();
GLUtil.setupDebugMessageCallback();
}
private void initializeListeners()
{
// Click listener (for trackball events)
canvas.addMouseListener(new MouseListener()
{
@Override
public void mouseUp(MouseEvent e)
{
isDragging = false;
start = null;
end = null;
}
@Override
public void mouseDown(MouseEvent e)
{
// If this is the first mouse down we record the mouse start location
if (!isDragging)
{
start = getArcballVector(e.x, e.y);
}
isDragging = true;
}
@Override
public void mouseDoubleClick(MouseEvent e)
{
// Nothing
}
});
// Move listener (for trackball drag)
canvas.addMouseMoveListener(new MouseMoveListener()
{
@Override
public void mouseMove(MouseEvent e)
{
if (isDragging)
{
end = getArcballVector(e.x, e.y);
}
}
});
}
private void initializeShaderProgram()
{
int errorCheckValue = GL11.glGetError();
// Load the shaders
vertexShaderID = this.loadShader("src/plot/vertexShader.glsl", GL20.GL_VERTEX_SHADER);
fragmentShaderID = this.loadShader("src/plot/fragmentShader.glsl", GL20.GL_FRAGMENT_SHADER);
// Create a new shader program that links both shaders
programID = GL20.glCreateProgram();
GL20.glAttachShader(programID, vertexShaderID);
GL20.glAttachShader(programID, fragmentShaderID);
// Position information will be attribute 0
GL20.glBindAttribLocation(programID, 0, VERTEX_ATTRIBUTE_POSITION);
// Color information will be attribute 1
GL20.glBindAttribLocation(programID, 1, VERTEX_ATTRIBUTE_COLOR);
GL20.glLinkProgram(programID);
GL20.glValidateProgram(programID);
errorCheckValue = GL11.glGetError();
if (errorCheckValue != GL11.GL_NO_ERROR)
{
System.out.println("ERROR - Could not create the shaders!");
System.exit(-1);
}
}
private void initializeScene()
{
// Model matrix
modelMatrixID = glGetUniformLocation(programID, VERTEX_FIELD_MODEL);
// Perspective matrix (closer objects should appear larger)
viewMatrixID = glGetUniformLocation(programID, VERTEX_FIELD_VIEW);
viewMatrix = new Matrix4f();
viewMatrix.identity();
// Perspective matrix (closer objects should appear larger)
projectionMatrixID = glGetUniformLocation(programID, VERTEX_FIELD_PROJECTION);
projectionMatrix = new Matrix4f();
projectionMatrix.identity();
eye = new Vector3f(0f, 0f, 10f);
look = new Vector3f(0f, 0f, 0f);
up = new Vector3f(0f, 1f, 0f);
}
private void updateScene()
{
canvas.setCurrent();
int w = canvas.getBounds().width;
int h = canvas.getBounds().height;
width = (float) w;
height = (float) h;
float aspect = width / height;
// Map the internal OpenGL coordinate system to the entire screen. Since the
// user can resize the window, we do this here.
GL11.glViewport(0, 0, w, h);
// Make sure to include depth testing and depth bit or we will see weird
// things
GL11.glClear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT);
GL11.glEnable(GL11.GL_DEPTH_TEST);
GL20.glUseProgram(programID);
updateViewPerspective(aspect);
// Shift cubes off the origin
cube1.getModelMatrix().setTranslation(-1f, 0f, 0f);
cube2.getModelMatrix().setTranslation(1f, 0f, 0f);
// Let's make cube 2 do crazy stuff like spin while orbiting the x axis
cube2.getModelMatrix().rotate(0.03f, 1f, 0f, 0f);
cube2.getModelMatrix().rotate(0.01f, 0f, 1f, 0f);
theta += 1 % 360;
cube2.getModelMatrix().setTranslation(1f, (float) Math.cos(Math.toRadians(theta)) * 2f,
(float) Math.sin(Math.toRadians(theta)) * 2f);
// Draw the cubes
cube1.draw(modelMatrixID);
cube2.draw(modelMatrixID);
canvas.swapBuffers();
}
private void updateViewPerspective(float aspect)
{
if (isDragging)
{
if (end != null && start != null)
{
float angle = -10 * (float) Math.min(Math.acos(start.dot(end)), 1f);
Vector3f axis = new Vector3f(start);
axis.cross(end);
Matrix3f vi = new Matrix3f(viewMatrix);
vi.invert();
axis.mul(vi);
if (!Float.isNaN(angle))
{
Quaternionf q = new Quaternionf(new AxisAngle4f(angle, axis));
eye.rotate(q);
up.rotate(q);
start = new Vector3f(end);
}
}
}
viewMatrix.setLookAt(eye, look, up);
viewMatrix.get(buffer);
glUniformMatrix4fv(viewMatrixID, false, buffer);
projectionMatrix.setPerspective((float) Math.toRadians(45), aspect, 0.1f, 100f);
projectionMatrix.get(buffer);
glUniformMatrix4fv(projectionMatrixID, false, buffer);
}
private Vector3f getArcballVector(int x, int y)
{
Vector3f p = new Vector3f();
p.x = 2f * x / width - 1f;
p.y = 1f - 2f * y / height;
float squared = p.x * p.x + p.y * p.y;
if (squared <= 1)
{
p.z = (float) Math.sqrt(1 - squared);
}
else
{
p.normalize();
}
return p;
}
public int loadShader(String filename, int type)
{
StringBuilder shaderSource = new StringBuilder();
int shaderID = 0;
try
{
BufferedReader reader = new BufferedReader(new FileReader(filename));
String line;
while ( ( line = reader.readLine() ) != null)
{
shaderSource.append(line).append("\n");
}
reader.close();
}
catch (IOException e)
{
System.err.println("Could not read file.");
e.printStackTrace();
System.exit(-1);
}
shaderID = GL20.glCreateShader(type);
GL20.glShaderSource(shaderID, shaderSource);
GL20.glCompileShader(shaderID);
return shaderID;
}
private void start()
{
Thread t = new Thread(new Runnable()
{
@Override
public void run()
{
while (!TrackballTest.this.isDisposed())
{
TrackballTest.this.getDisplay().asyncExec(new Runnable()
{
@Override
public void run()
{
updateScene();
}
});
try
{
// Approximate a 30 FPS update rate for now. We'll get more fancy
// later on.
Thread.sleep(1000 / 30);
}
catch (InterruptedException e)
{
e.printStackTrace();
}
}
}
});
t.start();
}
public static void main(String[] args)
{
Display d = new Display();
Shell s = new Shell(d);
int width = 1024;
int height = 720;
s.setSize(width, height);
s.setLayout(new FillLayout());
new TrackballTest(s, SWT.NONE);
s.open();
while (!s.isDisposed())
{
if (!d.readAndDispatch())
{
d.sleep();
}
}
try
{
d.dispose();
}
catch (SWTException e)
{
// Do nothing
System.exit(0);
}
}
}
Cube Class
package shape;
import static org.lwjgl.opengl.GL20.glUniformMatrix4fv;
import java.nio.ByteBuffer;
import java.nio.FloatBuffer;
import org.joml.Matrix4f;
import org.lwjgl.BufferUtils;
import org.lwjgl.opengl.GL11;
import org.lwjgl.opengl.GL15;
import org.lwjgl.opengl.GL20;
import org.lwjgl.opengl.GL30;
public class Cube
{
// Quad variables
private int vaoId = 0;
private int verticesBufferId = 0;
private int verticesColorBufferId = 0;
private int verticesIndicesBufferId = 0;
private int numberOfIndices = 0;
private Matrix4f modelMatrix = new Matrix4f();
private FloatBuffer buffer = BufferUtils.createFloatBuffer(16);
public Cube()
{
float[] vertices = { -0.5f, -0.5f, 0.5f, 1f, // 0
0.5f, -0.5f, 0.5f, 1f, // 1
0.5f, 0.5f, 0.5f, 1f, // 2
-0.5f, 0.5f, 0.5f, 1f, // 3
-0.5f, -0.5f, -0.5f, 1f, // 4
0.5f, -0.5f, -0.5f, 1f, // 5
0.5f, 0.5f, -0.5f, 1f, // 6
-0.5f, 0.5f, -0.5f, 1f, // 7
};
FloatBuffer verticesBuffer = BufferUtils.createFloatBuffer(vertices.length);
verticesBuffer.put(vertices);
verticesBuffer.flip();
float[] colors = { 1f, 0f, 0f, 1f, // 1
0f, 1f, 0f, 1f, // 2
0f, 0f, 1f, 1f, // 3
1f, 1f, 1f, 1f, // 7
1f, 0f, 0f, 1f, // 4
0f, 1f, 0f, 1f, // 5
0f, 0f, 1f, 1f, // 6
1f, 1f, 1f, 1f, // 7
};
FloatBuffer colorsBuffer = BufferUtils.createFloatBuffer(colors.length);
colorsBuffer.put(colors);
colorsBuffer.flip();
byte[] indices = { 0, 1, 2, // front1
2, 3, 0,// front2
3, 2, 6, // top 1
6, 7, 3,// top 2
7, 6, 5, // back 1
5, 4, 7,// back 2
4, 5, 1, // bottom 1
1, 0, 4, // bottom 2
4, 0, 3, // left 1
3, 7, 4,// left 2
1, 5, 6, // right 1
6, 2, 1, // right 2
};
numberOfIndices = indices.length;
ByteBuffer indicesBuffer = BufferUtils.createByteBuffer(numberOfIndices);
indicesBuffer.put(indices);
indicesBuffer.flip();
// Create and bind a new VAO
vaoId = GL30.glGenVertexArrays();
GL30.glBindVertexArray(vaoId);
// Create and bind a VBO for the vertices
verticesBufferId = GL15.glGenBuffers();
GL15.glBindBuffer(GL15.GL_ARRAY_BUFFER, verticesBufferId);
GL15.glBufferData(GL15.GL_ARRAY_BUFFER, verticesBuffer, GL15.GL_STATIC_DRAW);
GL20.glVertexAttribPointer(0, 4, GL11.GL_FLOAT, false, 0, 0);
GL15.glBindBuffer(GL15.GL_ARRAY_BUFFER, 0);
// Create and bind a VBO for the colors
verticesColorBufferId = GL15.glGenBuffers();
GL15.glBindBuffer(GL15.GL_ARRAY_BUFFER, verticesColorBufferId);
GL15.glBufferData(GL15.GL_ARRAY_BUFFER, colorsBuffer, GL15.GL_STATIC_DRAW);
GL20.glVertexAttribPointer(1, 4, GL11.GL_FLOAT, false, 0, 0);
GL15.glBindBuffer(GL15.GL_ARRAY_BUFFER, 0);
// Create and bind a VBO for the indices
verticesIndicesBufferId = GL15.glGenBuffers();
GL15.glBindBuffer(GL15.GL_ELEMENT_ARRAY_BUFFER, verticesIndicesBufferId);
GL15.glBufferData(GL15.GL_ELEMENT_ARRAY_BUFFER, indicesBuffer, GL15.GL_STATIC_DRAW);
GL15.glBindBuffer(GL15.GL_ELEMENT_ARRAY_BUFFER, 0);
}
public void draw(int modelMatrixID)
{
// Bind the VAO
GL30.glBindVertexArray(vaoId);
GL20.glEnableVertexAttribArray(0);
GL20.glEnableVertexAttribArray(1);
// Bind the indices VBO
GL15.glBindBuffer(GL15.GL_ELEMENT_ARRAY_BUFFER, verticesIndicesBufferId);
// Draw the vertices
GL11.glDrawElements(GL11.GL_TRIANGLES, numberOfIndices, GL11.GL_UNSIGNED_BYTE, 0);
modelMatrix.get(buffer);
glUniformMatrix4fv(modelMatrixID, false, buffer);
}
public Matrix4f getModelMatrix()
{
return modelMatrix;
}
}
Vertex Shader
#version 150 core
in vec4 position;
in vec4 color;
out vec4 vertexColor;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main() {
mat4 mvp = projection * view * model;
gl_Position = mvp * position;
vertexColor = color;
}
Fragment shader
#version 150 core
in vec4 vertexColor;
out vec4 fragColor;
void main() {
fragColor = vertexColor;
}
I've already done this in the joml-camera (https://github.com/JOML-CI/joml-camera) project. See: ArcBallCamera.java (https://github.com/JOML-CI/joml-camera/blob/master/src/org/joml/camera/ArcBallCamera.java)
A nice additional feature is the lazy/momentum-based movement. But you can easily get that out, if not desired.
Demo is in the joml-lwjgl3-demos (https://github.com/JOML-CI/joml-lwjgl3-demos) project. See: ArcBallCameraDemo.java (https://github.com/JOML-CI/joml-lwjgl3-demos/blob/master/src/org/joml/lwjgl/ArcBallCameraDemo.java)
Essentially, what you are looking for is the following matrix:
m.translate(0, 0, zoom)
.rotateX(rotationUpDown)
.rotateY(rotationLeftRight)
.translate(negatedCenterPosition);
This applies the arcball view transformation to the matrix 'm'.
Quote from: Automaton on September 29, 2015, 20:49:47...I want to rotate the "camera" about the origin of the scene using a trackball, as opposed to translating+rotating each object within the scene in order to achieve the same effect.
Well, strictly and mathematically speaking there is only "one" possible method to rotate/translate/transform something in OpenGL.
And that is: you always transform the objects, you are rendering, "into" the camera view. There is no other way to transform something in OpenGL. Methodically you always start from the model-space vertices and transform them into camera-space by matrix-vector multiplications.
It's just that functions like gluLookAt make us think that it's the other way around and we are dealing with some kind of "camera." To achieve this, this function makes us think that the camera is transformed in the world so that it views the objects within the world. So, it is the opposite of what OpenGL really does - not transforming the objects into view space, but the view "into" object space.
Both transformations are indeed the "inverse" of each other, as in "matrix inverse."
And this is exactly what gluLookAt/Matrix4f.lookAt does: It interfaces us with the "intuitive" camera parameters (position, center, up), but internally it builds the inverse of that transformation to have it the way OpenGL works, by transforming the objects.
Hey Kai, thanks for the explanation and for the code. I really like your ArcBall implementation. I was able to hack it in really easily and it works as expected (plus some cool momentum)!
Just to make sure I'm interpreting things correctly, you say "lazy", is this 'cause you're approximating the angle as opposed to calculating it:
cam.setAlpha(cam.getAlpha() + Math.toRadians( ( x - mouseX ) * 0.1f));
cam.setBeta(cam.getBeta() + Math.toRadians( ( mouseY - y ) * 0.1f));
Quote from: Automaton on September 29, 2015, 22:10:24
I really like your ArcBall implementation.
Thank you! That's nice to hear. Someone finally making use of the arcball camera, instead of asking for the promised "free look" camera. ;)
You can always ask me/tell me if you need anything changed with that. Currently you are the first one I know of using it, so we could change anything from behaviour to API.
However, I will make the "movement/rotation integrator" swappable.
The existing with "momentum" and one with "direct"/choppy/instant movement, and maybe also an option to restrict the rotation to discrete steps of X degrees, for exact controlled rotation, as is desirable with 3D content creation tools.
Quote from: Automaton on September 29, 2015, 22:10:24
Just to make sure I'm interpreting things correctly, you say "lazy", is this 'cause you're approximating the angle as opposed to calculating it
The quoted code is also "exact" and does not approximate. It just increments the angles by the difference of the mouse positions. Dunno what you mean by "approximating."
QuoteThe quoted code is also "exact" and does not approximate. It just increments the angles by the difference of the mouse positions. Dunno what you mean by "approximating."
Sorry that it took me a while to respond. I needed to understand exactly what is different from what I was expecting and what I was observing and it's not the calculation of the angle, but the axis about which the rotation is occurring that was throwing me off.
I was thinking in terms of projecting onto an imaginary sphere as opposed to simple increments proportional to mouse movement. I actually like your method better. It's more intuitive. It is different from most 3D cad systems I have worked in, however.
As an example, here is what ArcBallCamera does:
(http://i.imgur.com/xTDQ0D3.jpg)
Mouse motion in the window-y-axis of the screen rotates the cube around the model-x-axis until you are looking down from the top at which point gimbal lock is encountered and continued motion in the window-y-axis instead rotates the cube around the model-y-axis.
Here is what Solidworks does and what I was initially trying to achieve:
(http://i.imgur.com/6cl3jB8.jpg)
The gimbal doesn't lock at the top and continued mouse motion in the window-y-axis continues to rotate the cube about the model-x-axis.
As I said, for most general users with little 3D experience (my users), I think your implementation is actually more intuitive, but I was initially expecting what I had become used to using solidworks.
I'm not actually sure what mathematically causes the gimbal lock. Perhaps a preservation of what "up" is? If you are considering changes to your ArcBall implementation it may be convenient to be able to switch/specify which type of motion to use as well as toggling acceleration/momentum.
Quote from: Automaton on September 30, 2015, 15:38:53
...until you are looking down from the top at which point gimbal lock is encountered.
I'm not actually sure what mathematically causes the gimbal lock. Perhaps a preservation of what "up" is?
This one is really easy to "fix." I intended the motion to stop when the "poles" were reached.
This is achieved by the range check in the setBeta(float) (https://github.com/JOML-CI/joml-camera/blob/master/src/org/joml/camera/ArcBallCamera.java#L78) method.
Remove that and you have a continuous rotation. However, then the Y axis is inverted.
That's why I added the angle restriction.