-
Notifications
You must be signed in to change notification settings - Fork 103
Design
This article explains the design decisions behind JOML.
When working with vectors, we would really like to do this:
v = (1, 2, 3);
w = (2, 3, 4);
z = v + w * 2;
However, this is not valid Java syntax for three reasons:
- the use of implicit types
- the use of the illegal parentheses as vector constructor
- the use of the non-overloaded + and * operators that only work on primitives
Because Java does not have operator overloading, JOML needs to find a way to make vector arithmetic as painless as possible in Java. The natural way to do this is to think about operators as functions with an infix notation. If we then give the symbols valid Java method names, we can make operators work like this:
v = (1, 2, 3);
w = (2, 3, 4);
z = v.add(w.mul(2));
This solves the last of the above problems but keeps the first two.
We must now find a way to construct vectors in Java. And what would be more reasonable than using a Java constructor. :) With constructors we have to name our vector types. JOML chooses Vector3f to mean a vector comprising three single-precision floating point values:
Vector3f v = new Vector3f(1, 2, 3);
Vector3f w = new Vector3f(2, 3, 4);
Vector3f z = v.add(w.mul(2));
The above is now valid Java, and in fact it would work this way in JOML. But in Java there is no such thing as "value types." This means that your vectors are objects in Java memory that have identity. Therefore, we must now define the semantics of each vector operation, specifying exactly what arguments that operation takes and what exactly the return value is, in terms of Java object identities.
The general contract for simple operators in JOML is the following:
- the operator is implemented at least as instance method of the class of the left operand
- in the case of a binary operator the method takes a single argument which is the right operand
- if nothing else is mentioned then the result of the operation is written back to the object on which the method is invoked (i.e. the left operand)
- in the case of a unary operator the method does not take any arguments and the operation is applied to the object on which the method is invoked
- the return value is the object on which the method is invoked
- it is guaranteed for every operator that no operation produces wrong results due to the left and right operands being the identical/same Java objects
An example of a binary operator implemented as instance method is add:
Vector3f v1 = new Vector3f(3, 2, 1);
Vector3f v2 = new Vector3f(1, 2, 3);
v1.add(v2);
The result is the component-wise sum of v1 and v2 which is then written back into v1. In this case, v1 would now represent the vector (4, 4, 4).
An example of a unary operator:
Vector3f v1 = new Vector3f(3, 2, 1);
v1.negate();
The variable v1 will now represent the vector (-3, -2, -1).
Optionally, the operator is implemented as a static method. If this is the case, then:
- the method is declared in the class of the left operand
- in the case of a binary operator the method takes three arguments. First the left operand, then the second operand and lastly the destination object into which to store the result
- in the case of a unary operator the method takes two arguments. First the single operand and then the destination object into which to store the result
- the method does not return a value (its return type is void)
- it is guaranteed that no operation produces wrong results due to the left and right operands being the identical/same Java objects
An example of this is:
Vector3f v1 = new Vector3f(3, 2, 1);
Vector3f v2 = new Vector3f(1, 2, 3);
Vector3f.add(v1, v2, v1);
Some methods that take vectors as parameters also have an overload taking the vector's components as separate parameters. This helps in reducing the allocations of temporary objects if you do not want to do any calculations on those vectors but merely use them as arguments for other methods.
One example of this is Matrix4f.lookAt(). It reflects the GLU function gluLookAt() and takes the three components of eye, center and up as separate float primitives.
All transformation methods provided by Matrix4f come in two fashions:
- as an instance method that post-multiplies the transformation to the Matrix4f object on which it is invoked
- as an instance method that sets the Matrix4f to that transformation, regardless of what that Matrix4f was before
Post-multiplying means that whenever we have a matrix A the result of calling a transformation method on A, such as scale(), will set A to be the result of A x S, where S denotes that scale transformation. This post-multiplication is also exactly what OpenGL does with its own matrix stack operations, such as glTranslate(), glScale() and also with the GLU function gluLookAt().
Matrix4f m = new Matrix4f();
m.translate(3.0f, 0.0f, 0.0f)
.scale(0.5f);
The above example stores a transformation in m that effectively is S x T where S denotes the scaling transformation and T the translation. This means that any vector transformed by this transformation matrix will first get its components scaled by 0.5 and then added 3.0 to x:
Vector4f v = new Vector4f(4.0f, 2.0f, 2.0f, 1.0f);
m.transform(v);
After this transformation, v will represent the vector (5, 1, 1).