feat(math): add Matrix3x3 and Quaternion for 3D transformations
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Fri, 20 Mar 2026 20:51:42 +0000 (22:51 +0200)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Fri, 20 Mar 2026 20:51:42 +0000 (22:51 +0200)
- Add Matrix3x3 class for 3D transformations
- Add toMatrix method to Rotation with test
- Use matrix for point transformation
- Cache transformation matrix for performance
- Add Quaternion class for 3D rotations
- Use quaternion internally instead of Euler angles
- Use quaternion-based camera rotation
- Use rotation matrix for movement direction
- Use quaternion for lookAt rotation

src/main/java/eu/svjatoslav/sixth/e3d/gui/Camera.java
src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/InputManager.java
src/main/java/eu/svjatoslav/sixth/e3d/math/Matrix3x3.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/sixth/e3d/math/Quaternion.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/sixth/e3d/math/Rotation.java
src/main/java/eu/svjatoslav/sixth/e3d/math/Transform.java
src/test/java/eu/svjatoslav/sixth/e3d/math/QuaternionTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/sixth/e3d/math/RotationMatrixTest.java [new file with mode: 0644]

index 2f0b842..f5542e6 100644 (file)
@@ -5,6 +5,8 @@
 package eu.svjatoslav.sixth.e3d.gui;
 
 import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.Matrix3x3;
+import eu.svjatoslav.sixth.e3d.math.Quaternion;
 import eu.svjatoslav.sixth.e3d.math.Transform;
 
 /**
@@ -168,27 +170,28 @@ public class Camera implements FrameListener {
      *                                         camera between consecutive frames.
      */
     private void translateCameraLocationBasedOnMovementVector(int millisecondsPassedSinceLastFrame) {
-        final double sinXZ = transform.getRotation().getSinXZ();
-        final double cosXZ = transform.getRotation().getCosXZ();
+        final Matrix3x3 m = transform.getRotation().toMatrix();
+
+        final double forwardX = m.m20;
+        final double forwardY = m.m21;
+        final double forwardZ = m.m22;
+
+        final double rightX = m.m00;
+        final double rightY = m.m01;
+        final double rightZ = m.m02;
 
         final Point3D location = transform.getTranslation();
+        final double ms = millisecondsPassedSinceLastFrame;
+
+        location.x += forwardX * movementVector.z * SPEED_MULTIPLIER * ms;
+        location.y += forwardY * movementVector.z * SPEED_MULTIPLIER * ms;
+        location.z += forwardZ * movementVector.z * SPEED_MULTIPLIER * ms;
+
+        location.x += rightX * movementVector.x * SPEED_MULTIPLIER * ms;
+        location.y += rightY * movementVector.x * SPEED_MULTIPLIER * ms;
+        location.z += rightZ * movementVector.x * SPEED_MULTIPLIER * ms;
 
-        location.x -= (float) sinXZ
-                * movementVector.z * SPEED_MULTIPLIER
-                * millisecondsPassedSinceLastFrame;
-        location.z += (float) cosXZ
-                * movementVector.z * SPEED_MULTIPLIER
-                * millisecondsPassedSinceLastFrame;
-
-        location.x += (float) cosXZ
-                * movementVector.x * SPEED_MULTIPLIER
-                * millisecondsPassedSinceLastFrame;
-        location.z += (float) sinXZ
-                * movementVector.x * SPEED_MULTIPLIER
-                * millisecondsPassedSinceLastFrame;
-
-        location.y += movementVector.y * SPEED_MULTIPLIER
-                * millisecondsPassedSinceLastFrame;
+        location.y += movementVector.y * SPEED_MULTIPLIER * ms;
     }
 
     /**
@@ -226,6 +229,6 @@ public class Camera implements FrameListener {
         final double horizontalDist = Math.sqrt(dx * dx + dz * dz);
         final double angleYZ = -Math.atan2(dy, horizontalDist);
 
-        transform.setRotation(angleXZ, angleYZ);
+        transform.getRotation().setQuaternion(Quaternion.fromAngles(angleXZ, angleYZ));
     }
 }
\ No newline at end of file
index 5b6db3d..dc038aa 100644 (file)
@@ -5,9 +5,11 @@
 package eu.svjatoslav.sixth.e3d.gui.humaninput;
 
 import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
 import eu.svjatoslav.sixth.e3d.gui.Camera;
 import eu.svjatoslav.sixth.e3d.gui.FrameListener;
 import eu.svjatoslav.sixth.e3d.gui.ViewPanel;
+import eu.svjatoslav.sixth.e3d.math.Quaternion;
 import eu.svjatoslav.sixth.e3d.math.Rotation;
 
 import java.awt.*;
@@ -41,6 +43,8 @@ public class InputManager implements
     private Point2D currentMouseLocation;
     private boolean mouseMoved;
     private boolean mouseWithinWindow = false;
+    private double cameraYaw = 0;
+    private double cameraPitch = 0;
 
     /**
      * Creates an input manager attached to the given view panel.
@@ -263,15 +267,21 @@ public class InputManager implements
     }
 
     private boolean handleMouseDragging() {
+        if (mouseDelta.isZero()) {
+            return false;
+        }
+
+        cameraYaw -= mouseDelta.x / 50.0;
+        cameraPitch -= mouseDelta.y / 50.0;
+
+        cameraPitch = Math.max(-Math.PI / 2 + 0.001,
+                      Math.min( Math.PI / 2 - 0.001, cameraPitch));
+
         final Camera camera = viewPanel.getCamera();
-        Rotation rotation = camera.getTransform().getRotation();
-        final double newXZ = rotation.getAngleXZ() - ((float) mouseDelta.x / 50);
-        final double newYZ = rotation.getAngleYZ() - ((float) mouseDelta.y / 50);
-        camera.getTransform().setRotation(newXZ, newYZ);
+        camera.getTransform().getRotation().setQuaternion(Quaternion.fromAngles(cameraYaw, cameraPitch));
 
-        boolean viewUpdateNeeded = !mouseDelta.isZero();
         mouseDelta.zero();
-        return viewUpdateNeeded;
+        return true;
     }
 
 }
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/math/Matrix3x3.java b/src/main/java/eu/svjatoslav/sixth/e3d/math/Matrix3x3.java
new file mode 100644 (file)
index 0000000..9dbc255
--- /dev/null
@@ -0,0 +1,67 @@
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.math;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+
+/**
+ * A 3x3 matrix for 3D transformations.
+ *
+ * <p>Matrix elements are stored in row-major order:</p>
+ * <pre>
+ * | m00 m01 m02 |
+ * | m10 m11 m12 |
+ * | m20 m21 m22 |
+ * </pre>
+ *
+ * @see Point3D
+ */
+public class Matrix3x3 {
+
+    public double m00;
+    public double m01;
+    public double m02;
+    public double m10;
+    public double m11;
+    public double m12;
+    public double m20;
+    public double m21;
+    public double m22;
+
+    /**
+     * Creates a zero matrix.
+     */
+    public Matrix3x3() {
+    }
+
+    /**
+     * Returns an identity matrix.
+     *
+     * @return a new identity matrix
+     */
+    public static Matrix3x3 identity() {
+        final Matrix3x3 m = new Matrix3x3();
+        m.m00 = 1;
+        m.m11 = 1;
+        m.m22 = 1;
+        return m;
+    }
+
+    /**
+     * Applies this matrix transformation to a point.
+     *
+     * @param in  the input point (not modified)
+     * @param out the output point (will be modified)
+     */
+    public void transform(final Point3D in, final Point3D out) {
+        final double x = m00 * in.x + m01 * in.y + m02 * in.z;
+        final double y = m10 * in.x + m11 * in.y + m12 * in.z;
+        final double z = m20 * in.x + m21 * in.y + m22 * in.z;
+        out.x = x;
+        out.y = y;
+        out.z = z;
+    }
+
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/math/Quaternion.java b/src/main/java/eu/svjatoslav/sixth/e3d/math/Quaternion.java
new file mode 100644 (file)
index 0000000..86aef8a
--- /dev/null
@@ -0,0 +1,136 @@
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.math;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+
+import static java.lang.Math.cos;
+import static java.lang.Math.sin;
+
+/**
+ * A unit quaternion representing a 3D rotation.
+ *
+ * <p>Quaternions provide a compact representation of rotations that avoids
+ * gimbal lock and enables smooth interpolation (slerp).</p>
+ *
+ * @see Matrix3x3
+ * @see Rotation
+ */
+public class Quaternion {
+
+    public double w;
+    public double x;
+    public double y;
+    public double z;
+
+    /**
+     * Creates a quaternion with the specified components.
+     *
+     * @param w the scalar component
+     * @param x the i component
+     * @param y the j component
+     * @param z the k component
+     */
+    public Quaternion(final double w, final double x, final double y, final double z) {
+        this.w = w;
+        this.x = x;
+        this.y = y;
+        this.z = z;
+    }
+
+    /**
+     * Returns the identity quaternion representing no rotation.
+     *
+     * @return the identity quaternion (1, 0, 0, 0)
+     */
+    public static Quaternion identity() {
+        return new Quaternion(1, 0, 0, 0);
+    }
+
+    /**
+     * Creates a quaternion from an axis-angle representation.
+     *
+     * @param axis  the rotation axis (must be normalized)
+     * @param angle the rotation angle in radians
+     * @return a quaternion representing the rotation
+     */
+    public static Quaternion fromAxisAngle(final Point3D axis, final double angle) {
+        final double halfAngle = angle / 2;
+        final double s = sin(halfAngle);
+        final double c = cos(halfAngle);
+        return new Quaternion(c, axis.x * s, axis.y * s, axis.z * s);
+    }
+
+    /**
+     * Creates a quaternion from XZ (yaw) and YZ (pitch) Euler angles.
+     *
+     * <p>The rotation is composed as yaw (around Y axis) followed by
+     * pitch (around X axis).</p>
+     *
+     * @param angleXZ the angle around the XZ axis (yaw) in radians
+     * @param angleYZ the angle around the YZ axis (pitch) in radians
+     * @return a quaternion representing the combined rotation
+     */
+    public static Quaternion fromAngles(final double angleXZ, final double angleYZ) {
+        final Quaternion yaw = fromAxisAngle(new Point3D(0, 1, 0), angleXZ);
+        final Quaternion pitch = fromAxisAngle(new Point3D(1, 0, 0), -angleYZ);
+        return pitch.multiply(yaw);
+    }
+
+    /**
+     * Multiplies this quaternion by another (Hamilton product).
+     *
+     * @param other the quaternion to multiply by
+     * @return a new quaternion representing the combined rotation
+     */
+    public Quaternion multiply(final Quaternion other) {
+        return new Quaternion(
+            w * other.w - x * other.x - y * other.y - z * other.z,
+            w * other.x + x * other.w + y * other.z - z * other.y,
+            w * other.y - x * other.z + y * other.w + z * other.x,
+            w * other.z + x * other.y - y * other.x + z * other.w
+        );
+    }
+
+    /**
+     * Normalizes this quaternion to unit length.
+     *
+     * @return this quaternion (for chaining)
+     */
+    public Quaternion normalize() {
+        final double len = Math.sqrt(w * w + x * x + y * y + z * z);
+        if (len > 0) {
+            w /= len;
+            x /= len;
+            y /= len;
+            z /= len;
+        }
+        return this;
+    }
+
+    /**
+     * Converts this quaternion to a 3x3 rotation matrix.
+     *
+     * @return a new matrix representing this rotation
+     */
+    public Matrix3x3 toMatrix3x3() {
+        final Matrix3x3 m = new Matrix3x3();
+
+        m.m00 = 1 - 2 * (y * y + z * z);
+        m.m01 = 2 * (x * y - w * z);
+        m.m02 = 2 * (x * z + w * y);
+
+        m.m10 = 2 * (x * y + w * z);
+        m.m11 = 1 - 2 * (x * x + z * z);
+        m.m12 = 2 * (y * z - w * x);
+
+        m.m20 = 2 * (x * z - w * y);
+        m.m21 = 2 * (y * z + w * x);
+        m.m22 = 1 - 2 * (x * x + y * y);
+
+        return m;
+    }
+
+}
\ No newline at end of file
index b76ce06..236be0d 100644 (file)
@@ -6,39 +6,26 @@ package eu.svjatoslav.sixth.e3d.math;
 
 import eu.svjatoslav.sixth.e3d.geometry.Point3D;
 
-import static java.lang.Math.cos;
-import static java.lang.Math.sin;
+import static java.lang.Math.atan2;
 
 /**
- * Represents a rotation in 3D space using two Euler angles (XZ and YZ).
+ * Represents a rotation in 3D space using a quaternion internally.
  *
- * <p>Angles are stored with precomputed sine and cosine values for efficient
- * repeated rotation operations without recalculating trigonometric functions.</p>
+ * <p>Historically used two Euler angles (XZ and YZ). Now stores a quaternion
+ * for better numerical stability and to avoid gimbal lock.</p>
  *
  * @see Transform
+ * @see Quaternion
  */
 public class Rotation implements Cloneable {
 
-    /**
-     * Precomputed sine and cosine of the rotation angles.
-     */
-    private double s1, c1, s2, c2;
-
-    /**
-     * The angle of rotation around the XZ axis (yaw).
-     */
-    private double angleXZ = 0;
-
-    /**
-     * The angle of rotation around the YZ axis (pitch).
-     */
-    private double angleYZ = 0;
+    private Quaternion quaternion;
 
     /**
-     * Creates a rotation with no rotation (zero angles).
+     * Creates a rotation with no rotation (identity).
      */
     public Rotation() {
-        computeMultipliers();
+        quaternion = Quaternion.identity();
     }
 
     /**
@@ -48,116 +35,116 @@ public class Rotation implements Cloneable {
      * @param angleYZ the angle around the YZ axis (pitch) in radians
      */
     public Rotation(final double angleXZ, final double angleYZ) {
-        this.angleXZ = angleXZ;
-        this.angleYZ = angleYZ;
-        computeMultipliers();
+        quaternion = Quaternion.fromAngles(angleXZ, angleYZ);
     }
 
     /**
-     * Creates a copy of this rotation with the same angles.
+     * Creates a copy of this rotation with the same orientation.
      *
-     * @return a new rotation with the same angle values
+     * @return a new rotation with the same quaternion values
      */
     @Override
     public Rotation clone() {
-        return new Rotation(angleXZ, angleYZ);
+        final Rotation r = new Rotation();
+        r.quaternion = new Quaternion(quaternion.w, quaternion.x, quaternion.y, quaternion.z);
+        return r;
     }
 
     /**
-     * Recomputes the sine and cosine values from the current angles.
-     */
-    private void computeMultipliers() {
-        s1 = sin(angleXZ);
-        c1 = cos(angleXZ);
-
-        s2 = sin(angleYZ);
-        c2 = cos(angleYZ);
-    }
-
-    /**
-     * Rotates a point around the origin using this rotation's angles.
+     * Rotates a point around the origin using this rotation.
      *
      * @param point3d the point to rotate (modified in place)
      */
     public void rotate(final Point3D point3d) {
-        // Rotate around the XZ axis
-        final double z1 = (point3d.z * c1) - (point3d.x * s1);
-        point3d.x = (point3d.z * s1) + (point3d.x * c1);
-
-        // Rotate around the YZ axis
-        point3d.z = (z1 * c2) - (point3d.y * s2);
-        point3d.y = (z1 * s2) + (point3d.y * c2);
+        toMatrix().transform(point3d, point3d);
     }
 
     /**
-     * Adds the specified angles to this rotation and updates the trigonometric values.
+     * Adds the specified angles to this rotation.
      *
      * @param angleXZ the angle to add around the XZ axis in radians
      * @param angleYZ the angle to add around the YZ axis in radians
      */
     public void rotate(final double angleXZ, final double angleYZ) {
-        this.angleXZ += angleXZ;
-        this.angleYZ += angleYZ;
-        computeMultipliers();
+        final Quaternion delta = Quaternion.fromAngles(angleXZ, angleYZ);
+        quaternion = delta.multiply(quaternion);
     }
 
     /**
-     * Sets the rotation angles and recomputes the trigonometric values.
+     * Sets the rotation angles.
      *
      * @param angleXZ the angle around the XZ axis (yaw) in radians
      * @param angleYZ the angle around the YZ axis (pitch) in radians
      */
     public void setAngles(final double angleXZ, final double angleYZ) {
-        this.angleXZ = angleXZ;
-        this.angleYZ = angleYZ;
-        computeMultipliers();
+        quaternion = Quaternion.fromAngles(angleXZ, angleYZ);
     }
 
     /**
-     * Copies the angles from another rotation into this rotation.
+     * Copies the rotation from another rotation.
      *
-     * @param rotation the rotation to copy angles from
+     * @param rotation the rotation to copy from
      */
-    public void setAngles(Rotation rotation) {
-        this.angleXZ = rotation.angleXZ;
-        this.angleYZ = rotation.angleYZ;
-        computeMultipliers();
+    public void setAngles(final Rotation rotation) {
+        quaternion = new Quaternion(
+            rotation.quaternion.w,
+            rotation.quaternion.x,
+            rotation.quaternion.y,
+            rotation.quaternion.z
+        );
+    }
+
+    /**
+     * Sets the rotation from a quaternion.
+     *
+     * @param q the quaternion to set
+     */
+    public void setQuaternion(final Quaternion q) {
+        quaternion = new Quaternion(q.w, q.x, q.y, q.z);
+    }
+
+    /**
+     * Returns the internal quaternion.
+     *
+     * @return the quaternion (not a copy)
+     */
+    public Quaternion getQuaternion() {
+        return quaternion;
     }
 
     /**
      * Returns the angle around the XZ axis (yaw) in radians.
      *
+     * <p>This is a compatibility shim that extracts the angle from the quaternion.
+     * May not be accurate for extreme pitch angles.</p>
+     *
      * @return the XZ angle
      */
     public double getAngleXZ() {
-        return angleXZ;
+        final Matrix3x3 m = toMatrix();
+        return atan2(m.m02, m.m00);
     }
 
     /**
      * Returns the angle around the YZ axis (pitch) in radians.
      *
+     * <p>This is a compatibility shim that extracts the angle from the quaternion.
+     * May not be accurate for extreme yaw angles.</p>
+     *
      * @return the YZ angle
      */
     public double getAngleYZ() {
-        return angleYZ;
-    }
-
-    /**
-     * Returns the precomputed sine of the XZ angle.
-     *
-     * @return sin(angleXZ)
-     */
-    public double getSinXZ() {
-        return s1;
+        final Matrix3x3 m = toMatrix();
+        return atan2(-m.m21, m.m11);
     }
 
     /**
-     * Returns the precomputed cosine of the XZ angle.
+     * Converts this rotation to a 3x3 transformation matrix.
      *
-     * @return cos(angleXZ)
+     * @return a matrix representing this rotation
      */
-    public double getCosXZ() {
-        return c1;
+    public Matrix3x3 toMatrix() {
+        return quaternion.toMatrix3x3();
     }
 
 }
\ No newline at end of file
index 8131ef6..021fe1a 100755 (executable)
@@ -102,7 +102,7 @@ public class Transform implements Cloneable {
      * @param point the point to transform (modified in place)
      */
     public void transform(final Point3D point) {
-        rotation.rotate(point);
+        rotation.toMatrix().transform(point, point);
         point.add(translation);
     }
 
diff --git a/src/test/java/eu/svjatoslav/sixth/e3d/math/QuaternionTest.java b/src/test/java/eu/svjatoslav/sixth/e3d/math/QuaternionTest.java
new file mode 100644 (file)
index 0000000..70d0488
--- /dev/null
@@ -0,0 +1,33 @@
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.math;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class QuaternionTest {
+
+    @Test
+    public void testFromAnglesMatchesRotation() {
+        final Rotation rotation = new Rotation(0.5, 0.3);
+        final Matrix3x3 rotationMatrix = rotation.toMatrix();
+
+        final Quaternion quaternion = Quaternion.fromAngles(0.5, 0.3);
+        final Matrix3x3 quaternionMatrix = quaternion.toMatrix3x3();
+
+        final double epsilon = 0.0001;
+        assertEquals(rotationMatrix.m00, quaternionMatrix.m00, epsilon);
+        assertEquals(rotationMatrix.m01, quaternionMatrix.m01, epsilon);
+        assertEquals(rotationMatrix.m02, quaternionMatrix.m02, epsilon);
+        assertEquals(rotationMatrix.m10, quaternionMatrix.m10, epsilon);
+        assertEquals(rotationMatrix.m11, quaternionMatrix.m11, epsilon);
+        assertEquals(rotationMatrix.m12, quaternionMatrix.m12, epsilon);
+        assertEquals(rotationMatrix.m20, quaternionMatrix.m20, epsilon);
+        assertEquals(rotationMatrix.m21, quaternionMatrix.m21, epsilon);
+        assertEquals(rotationMatrix.m22, quaternionMatrix.m22, epsilon);
+    }
+
+}
\ No newline at end of file
diff --git a/src/test/java/eu/svjatoslav/sixth/e3d/math/RotationMatrixTest.java b/src/test/java/eu/svjatoslav/sixth/e3d/math/RotationMatrixTest.java
new file mode 100644 (file)
index 0000000..d0d14e6
--- /dev/null
@@ -0,0 +1,31 @@
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.math;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class RotationMatrixTest {
+
+    @Test
+    public void testToMatrixMatchesRotate() {
+        final Rotation rotation = new Rotation(0.5, 0.3);
+        final Point3D original = new Point3D(1, 2, 3);
+
+        final Point3D viaRotate = new Point3D(original);
+        rotation.rotate(viaRotate);
+
+        final Point3D viaMatrix = new Point3D();
+        rotation.toMatrix().transform(original, viaMatrix);
+
+        final double epsilon = 0.0001;
+        assertEquals(viaRotate.x, viaMatrix.x, epsilon);
+        assertEquals(viaRotate.y, viaMatrix.y, epsilon);
+        assertEquals(viaRotate.z, viaMatrix.z, epsilon);
+    }
+
+}
\ No newline at end of file