Skip to content
Kai Burjack edited this page Oct 10, 2016 · 24 revisions

This article explains the design decisions behind JOML, following its design goals.

Operators are methods

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.

Classes and constructors

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.

Method contracts

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);

Vectors as primitives

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.

Post-multiplied transformations

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 functions gluLookAt() and gluPerspective().

Providing a post-multiplied version of a transformation operation has the following advantages:

  • You can chain multiple transformations together without explicit multiplication after each step
  • Multiplication can be performed more efficiently since with simple transformations most of the matrix elements are known to be zero or one. So JOML will not do a full matrix multiplication when you call scale() or translate()

The following is a simple example of using the post-multiplying methods in a fluent interface style:

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 T x S where T denotes the translation transformation and S the scaling. 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);
Matrix4f m = new Matrix4f();
m.translate(3.0f, 0.0f, 0.0f)
 .scale(0.5f)
 .transform(v);

After this transformation which we can write as T x S x v, v will represent the vector (5, 1, 1).

Overload with dest parameter

Methods whose computation result is a function of this and a supplied parameter, such as the Matrix4f methods that apply their transformations via post-multiplication, support an additional overload taking a dest parameter. These overloads allow to store the resulting transformation in another matrix different from this.

This is useful if you want to store the intermediate result of many transformation processes in separate matrices, as is shown in the following example:

Quaternion mirrorOrientation = ...;
Vector3f mirrorPosition = ...;
Matrix4f view = new Matrix4f();
Matrix4f reflect = new Matrix4f();
view.lookAt(0.0f, 1.0f, 3.0f,
            0.0f, 0.0f, 0.0f,
            0.0f, 1.0f, 0.0f)
    .reflect(mirrorOrientation, mirrorPosition, reflect);

In the above example, we store the normal view/camera transformation into view and a separate reflection matrix into reflect using the overload with a dest parameter in order to keep the view matrix intact.

Angles are given in radians

All methods in JOML that use angles as one or more parameters, such as Matrix4f.rotate or Matrix4f.perspective, expect the values to be in radians. When porting your OpenGL/GLU code to JOML therefore please make sure to convert to radians at those places by either using fractions of PI for typical values such as 90 or 180 degrees, or use Math.toRadians or dividing the degrees value by an application-defined constant of 180 * PI.

Java NIO Buffers

The vector and matrix classes support writing to and reading their values from a java.nio.ByteBuffer (and typed views of them). JOML makes the assumption that the only reason why a client would want to transfer JOML objects from/to a NIO Buffer is in order to communicate with lower-level APIs and native code, such as OpenGL when using an OpenGL wrapper/binding library like JOGL or LWJGL.

Because of this, by default JOML only supports writing to and reading from direct NIO Buffers, as opposed to Buffers which are wrappers of Java primitive arrays.

Whenever it is necessary to transfer JOML object to/from a non-direct NIO Buffer, the JVM argument -Djoml.nounsafe must be used. This disables all efficient memory operations that are optimized for direct NIO Buffers and uses a much slower method of copying the data.

Therefore, in order to achieve maximum performance, only direct NIO Buffers should be used.