import * as MathConstants from '../constants/math.js';
/**
* Utility class for 4x4 transformation matrices.
*
* Matrices are stored as flat 'Float32Array' values in column-major order.
*
* A matrix can be flattened into an array in two common ways:
* 1). row-major stores it row by row
* 2). column-major stores it column by column
*
* Example:
*
* [ m00 m01 m02 m03 ]
* [ m10 m11 m12 m13 ]
* [ m20 m21 m22 m23 ]
* [ m30 m31 m32 m33 ]
*
* Row-major : [ m00, m01, m02, m03, m10, m11, m12, m13, ... ]
* Column-major : [ m00, m10, m20, m30, m01, m11, m21, m31, ... ]
*
* To read or write a matrix cell in this flat array,
* convert its column and row position into an array index.
*
* 'row count' - is always '4', because each column of a 4x4 matrix contains 4 row values
* 'column index * row count' - gives the start index of the target column
* 'row index' - selects the value inside the target column
*
* The resulting formula is:
* 'cell index = column index * row count + row index'
*
* So:
* m20 -> cell index = 0 * 4 + 2 = 2
* m01 -> cell index = 1 * 4 + 0 = 4
*
* It is also important to highlight that, when a 4x4 matrix represents
* an 'affine transform', its translation part is stored in the last column.
*
* Conceptually, this translation column is multiplied
* by the input 'homogeneous' coordinate called 'w':
*
* [ ... ... ... tx ]
* [ ... ... ... ty ]
* [ ... ... ... tz ]
* [ ... ... ... w ]
*
* Here, 'tx, ty and tz' are 'x, y, z' translation components.
*
* When a transform is applied as 'transformed value = matrix * input value',
* the translation part contributes to the result through input 'w':
*
* 'new x = ... + tx * w'
* 'new y = ... + ty * w'
* 'new z = ... + tz * w'
*
* So, if 'w = 1', translation is added to the result.
* If 'w = 0', translation becomes zero and is ignored.
*
* Since 'tx', 'ty' and 'tz' are stored in the last column,
* their flat array indices are calculated by the same formula:
*
* tx -> m03 -> cell index = 3 * 4 + 0 = 12
* ty -> m13 -> cell index = 3 * 4 + 1 = 13
* tz -> m23 -> cell index = 3 * 4 + 2 = 14
*
* For a usual affine transform, the last row is: [ 0, 0, 0, 1 ]
* It keeps the input 'w' unchanged during multiplication.
*
* Matrix multiplication is ordered: 'left * right' is not the same as 'right * left'.
* When the result is applied to a column vector, the right matrix is applied first.
*/
export class Matrix4 {
/**
* Creates a new 4x4 identity matrix:
*
* column 0: [ 1, 0, 0, 0 ]
* column 1: [ 0, 1, 0, 0 ]
* column 2: [ 0, 0, 1, 0 ]
* column 3: [ 0, 0, 0, 1 ]
*
* @returns {Float32Array} - A new identity matrix.
*/
static createIdentity() {
const out = Matrix4.#createEmpty();
out[0] = 1;
out[5] = 1;
out[10] = 1;
out[15] = 1;
return out;
}
/**
* Creates a scale matrix:
*
* column 0: [ scaleX, 0, 0, 0 ]
* column 1: [ 0, scaleY, 0, 0 ]
* column 2: [ 0, 0, scaleZ, 0 ]
* column 3: [ 0, 0, 0, 1 ]
*
* @param {number} scaleX - Scale along X.
* @param {number} scaleY - Scale along Y.
* @param {number} scaleZ - Scale along Z.
* @returns {Float32Array} - A new scale matrix.
*/
static createScale(scaleX, scaleY, scaleZ) {
if (typeof scaleX !== 'number'
|| typeof scaleY !== 'number'
|| typeof scaleZ !== 'number') {
throw new TypeError('`Matrix4.createScale` expects numeric arguments.');
}
const out = Matrix4.#createEmpty();
out[0] = scaleX;
out[5] = scaleY;
out[10] = scaleZ;
out[15] = 1;
return out;
}
/**
* Creates a perspective projection matrix:
*
* column 0: [ projectionScale / aspectRatio, 0, 0, 0 ]
* column 1: [ 0, projectionScale, 0, 0 ]
* column 2: [ 0, 0, depthScale, -1 ]
* column 3: [ 0, 0, depthOffset, 0 ]
*
* Perspective projection:
* - makes farther objects appear smaller and closer objects appear larger
* - unlike the orthographic projection, object size depends on distance from the camera
*
* How the matrix is applied to a 3D point?
* - the matrix stores projection coefficients, not a projected point
* - a view-space 3D point [x, y, z] is used as a homogeneous input [x, y, z, 1]
* - homogeneous input means a 3D point, stored with a helper 'w-component', not a point in 4D space
* - 'w = 1' means a position, 'w = 0' means a direction, so the translation/depth offset wouldn't apply
*
* Multiplying this homogeneous form of the 3D point by the matrix produces the clip-space coordinates:
*
* clip x: [ x * projectionScale / aspectRatio ]
* clip y: [ y * projectionScale ]
* clip z: [ z * depthScale + 1 * depthOffset ]
* clip w: [ z * (-1) + 1 * 0 = -z ]
*
* About spaces:
* - world-space describes where an object is in the scene
* - view-space describes the same object relative to the camera, before the projection
* - the perspective projection matrix converts the 'view-space' coordinates into the 'clip-space' coordinates
* - clip-space stores 4 components: [clip x, clip y, clip z, clip w]
* - clip-space is where projected coordinates are checked against the visible camera volume
* - after clipping, the perspective divide ('/ clip w') converts clip-space coordinates into NDC coordinates
*
* Notes about the projection scale:
* - the scale uses the half-angle between the camera center line and the vertical view boundary (that's why tangent is used)
* - tangent describes how wide the vertical view opens from this half-angle
* - the inverse is used, because tangent gives the view opening, while the matrix needs a scale, that brings this opening back to the unit boundary
* - the final scale value is the inverse of that opening value (tangent of half of the vertical FOV)
*
* Depth mapping:
* - depth scale controls how the view-space z value is scaled for clip-space z
* - depth offset shifts the scaled z value so 'near' and 'far' can be mapped to the clip-space depth limits
*
* Formulas:
* - aspect ratio = viewport width / viewport height
* - vertical projection scale = inverse of tangent(half vertical field of view)
* - horizontal projection scale = vertical projection scale / aspect ratio
* - depth scale = (far + near) / (near - far)
* - depth offset = 2 * far * near / (near - far)
*
* @param {number} fieldOfViewRadians - Vertical field of view in radians.
* @param {number} aspectRatio - Viewport aspect ratio (width / height).
* @param {number} near - Near clipping plane, must be '> 0'.
* @param {number} far - Far clipping plane, must be '> near'.
* @returns {Float32Array} - A new perspective projection matrix.
* @throws {TypeError} - If any argument is not a finite number.
* @throws {RangeError} - If the projection parameters are invalid or produce non-finite matrix values.
*/
static createPerspective(fieldOfViewRadians, aspectRatio, near, far) {
if (!Number.isFinite(fieldOfViewRadians)
|| !Number.isFinite(aspectRatio)
|| !Number.isFinite(near)
|| !Number.isFinite(far)) {
throw new TypeError('`Matrix4.createPerspective` expects finite numeric arguments.');
}
if (fieldOfViewRadians <= MathConstants.MATH_CAMERA_LIMITS.MINIMUM_FIELD_OF_VIEW_RADIANS ||
fieldOfViewRadians >= MathConstants.MATH_CAMERA_LIMITS.MAXIMUM_FIELD_OF_VIEW_RADIANS) {
throw new RangeError('`Matrix4.createPerspective` expects `0 < fieldOfViewRadians < Math.PI`.');
}
if (aspectRatio <= MathConstants.MATH_CAMERA_LIMITS.MINIMUM_ASPECT_RATIO) {
throw new RangeError('`Matrix4.createPerspective` expects a positive aspect ratio.');
}
if (near <= MathConstants.MATH_CAMERA_LIMITS.MINIMUM_NEAR_CLIP_DISTANCE || far <= near) {
throw new RangeError('`Matrix4.createPerspective` expects `0 < near < far`.');
}
return Matrix4.#writePerspectiveIntoUnchecked(
Matrix4.#createEmpty(),
fieldOfViewRadians,
aspectRatio,
near,
far
);
}
/**
* Writes a perspective projection matrix to the provided output buffer.
*
* The output buffer is updated in place and returned without allocating a new matrix.
*
* @param {Float32Array} out - Output 4x4 matrix buffer.
* @param {number} fieldOfViewRadians - Vertical field of view in radians.
* @param {number} aspectRatio - Viewport width-to-height ratio.
* @param {number} near - Near clipping distance.
* @param {number} far - Far clipping distance.
* @returns {Float32Array} - The provided output buffer.
* @throws {TypeError} - If the output buffer is invalid or any argument is not a finite number.
* @throws {RangeError} - If the projection parameters are invalid or produce non-finite matrix values.
*/
static writePerspectiveTo(out, fieldOfViewRadians, aspectRatio, near, far) {
if (!(out instanceof Float32Array) || out.length !== MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT) {
throw new TypeError('`Matrix4.writePerspectiveTo` expects out to be `Float32Array(16)`.');
}
if (!Number.isFinite(fieldOfViewRadians)
|| !Number.isFinite(aspectRatio)
|| !Number.isFinite(near)
|| !Number.isFinite(far)) {
throw new TypeError('`Matrix4.writePerspectiveTo` expects finite numeric arguments.');
}
if (fieldOfViewRadians <= MathConstants.MATH_CAMERA_LIMITS.MINIMUM_FIELD_OF_VIEW_RADIANS ||
fieldOfViewRadians >= MathConstants.MATH_CAMERA_LIMITS.MAXIMUM_FIELD_OF_VIEW_RADIANS) {
throw new RangeError('`Matrix4.writePerspectiveTo` expects `0 < fieldOfViewRadians < Math.PI`.');
}
if (aspectRatio <= MathConstants.MATH_CAMERA_LIMITS.MINIMUM_ASPECT_RATIO) {
throw new RangeError('`Matrix4.writePerspectiveTo` expects a positive aspect ratio.');
}
if (near <= MathConstants.MATH_CAMERA_LIMITS.MINIMUM_NEAR_CLIP_DISTANCE || far <= near) {
throw new RangeError('`Matrix4.writePerspectiveTo` expects `0 < near < far`.');
}
return Matrix4.#writePerspectiveIntoUnchecked(
out,
fieldOfViewRadians,
aspectRatio,
near,
far
);
}
/**
* Writes an orthographic projection matrix to the provided output buffer.
*
* The output buffer is updated in place and returned without allocating a new matrix.
*
* @param {Float32Array} out - Output 4x4 matrix buffer.
* @param {number} left - Left clipping plane.
* @param {number} right - Right clipping plane.
* @param {number} bottom - Bottom clipping plane.
* @param {number} top - Top clipping plane.
* @param {number} near - Near clipping distance.
* @param {number} far - Far clipping distance.
* @returns {Float32Array} - The provided output buffer.
* @throws {TypeError} - If the output buffer or arguments are invalid.
* @throws {RangeError} - If the projection bounds or clipping distances are invalid.
*/
static writeOrthographicTo(out, left, right, bottom, top, near, far) {
if (!(out instanceof Float32Array) || out.length !== MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT) {
throw new TypeError('`Matrix4.writeOrthographicTo` expects out to be `Float32Array(16)`.');
}
if (typeof left !== 'number'
|| typeof right !== 'number'
|| typeof bottom !== 'number'
|| typeof top !== 'number'
|| typeof near !== 'number'
|| typeof far !== 'number') {
throw new TypeError('`Matrix4.writeOrthographicTo` expects numeric arguments.');
}
if (left === right) {
throw new RangeError('`Matrix4.writeOrthographicTo` expects `left !== right`.');
}
if (bottom === top) {
throw new RangeError('`Matrix4.writeOrthographicTo` expects `bottom !== top`.');
}
if (far <= near) {
throw new RangeError('`Matrix4.writeOrthographicTo` expects `near < far`.');
}
const inverseWidth = MathConstants.MATH_MATRIX_VALUES.UNIT / (right - left);
const inverseHeight = MathConstants.MATH_MATRIX_VALUES.UNIT / (top - bottom);
const inverseDepth = MathConstants.MATH_MATRIX_VALUES.UNIT / (near - far);
out[0] = MathConstants.MATH_ORTHOGRAPHIC.SCALE_NUMERATOR * inverseWidth;
out[1] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[2] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[3] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[4] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[5] = MathConstants.MATH_ORTHOGRAPHIC.SCALE_NUMERATOR * inverseHeight;
out[6] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[7] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[8] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[9] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[10] = MathConstants.MATH_ORTHOGRAPHIC.SCALE_NUMERATOR * inverseDepth;
out[11] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[12] = -(right + left) * inverseWidth;
out[13] = -(top + bottom) * inverseHeight;
out[14] = (far + near) * inverseDepth;
out[15] = MathConstants.MATH_MATRIX_VALUES.UNIT;
return out;
}
/**
* Creates a translation matrix:
*
* [ 1 0 0 translateX ]
* [ 0 1 0 translateY ]
* [ 0 0 1 translateZ ]
* [ 0 0 0 1 ]
*
* @param {number} translateX - Translation along X axis.
* @param {number} translateY - Translation along Y axis.
* @param {number} translateZ - Translation along Z axis.
* @returns {Float32Array} - A new translation matrix.
*/
static createTranslation(translateX, translateY, translateZ) {
if (typeof translateX !== 'number'
|| typeof translateY !== 'number'
|| typeof translateZ !== 'number') {
throw new TypeError('`Matrix4.createTranslation` expects numeric arguments.');
}
const out = Matrix4.createIdentity();
out[12] = translateX;
out[13] = translateY;
out[14] = translateZ;
return out;
}
/**
* Creates a rotation matrix around the X axis:
*
* [ 1 0 0 0 ]
* [ 0 cosAngle -sinAngle 0 ]
* [ 0 sinAngle cosAngle 0 ]
* [ 0 0 0 1 ]
*
* @param {number} angleRadians - Angle in radians.
* @returns {Float32Array} - A new rotation matrix.
*/
static createRotationX(angleRadians) {
if (typeof angleRadians !== 'number') {
throw new TypeError('`Matrix4.createRotationX` expects a numeric argument.');
}
const cosAngle = Math.cos(angleRadians);
const sinAngle = Math.sin(angleRadians);
const out = Matrix4.#createEmpty();
out[0] = 1;
out[1] = 0;
out[2] = 0;
out[3] = 0;
out[4] = 0;
out[5] = cosAngle;
out[6] = sinAngle;
out[7] = 0;
out[8] = 0;
out[9] = -sinAngle;
out[10] = cosAngle;
out[11] = 0;
out[12] = 0;
out[13] = 0;
out[14] = 0;
out[15] = 1;
return out;
}
/**
* Creates a rotation matrix around the Y axis:
*
* [ cosAngle 0 sinAngle 0 ]
* [ 0 1 0 0 ]
* [ -sinAngle 0 cosAngle 0 ]
* [ 0 0 0 1 ]
*
* @param {number} angleRadians - Angle in radians.
* @returns {Float32Array} - A new rotation matrix.
*/
static createRotationY(angleRadians) {
if (typeof angleRadians !== 'number') {
throw new TypeError('`Matrix4.createRotationY` expects a numeric argument.');
}
const cosAngle = Math.cos(angleRadians);
const sinAngle = Math.sin(angleRadians);
const out = Matrix4.#createEmpty();
out[0] = cosAngle;
out[1] = 0;
out[2] = -sinAngle;
out[3] = 0;
out[4] = 0;
out[5] = 1;
out[6] = 0;
out[7] = 0;
out[8] = sinAngle;
out[9] = 0;
out[10] = cosAngle;
out[11] = 0;
out[12] = 0;
out[13] = 0;
out[14] = 0;
out[15] = 1;
return out;
}
/**
* Creates a rotation matrix around the Z axis:
*
* [ cosAngle -sinAngle 0 0 ]
* [ sinAngle cosAngle 0 0 ]
* [ 0 0 1 0 ]
* [ 0 0 0 1 ]
*
* @param {number} angleRadians - Angle in radians.
* @returns {Float32Array} - A new rotation matrix.
*/
static createRotationZ(angleRadians) {
if (typeof angleRadians !== 'number') {
throw new TypeError('`Matrix4.createRotationZ` expects a numeric argument.');
}
const cosAngle = Math.cos(angleRadians);
const sinAngle = Math.sin(angleRadians);
const out = Matrix4.#createEmpty();
out[0] = cosAngle;
out[1] = sinAngle;
out[2] = 0;
out[3] = 0;
out[4] = -sinAngle;
out[5] = cosAngle;
out[6] = 0;
out[7] = 0;
out[8] = 0;
out[9] = 0;
out[10] = 1;
out[11] = 0;
out[12] = 0;
out[13] = 0;
out[14] = 0;
out[15] = 1;
return out;
}
/**
* Writes a transformation matrix combining translation, Euler rotation and scale.
*
* The transformation is calculated as:
* 'M = T * Rz * Ry * Rx * S'
*
* Where:
* M - final transformation matrix
* T - translation matrix
* Rx - rotation matrix around the X axis
* Ry - rotation matrix around the Y axis
* Rz - rotation matrix around the Z axis
* S - scale matrix
*
* For a point, the transformations are applied in reverse order:
* 'S -> Rx -> Ry -> Rz -> T'
*
* This happens because 'Matrix4' uses column vectors and applies the rightmost transformation first.
*
* This means that a point is transformed in the following sequence:
* 1). scaled along its local X, Y and Z axes
* 2). rotated around X
* 3). rotated around Y
* 4). rotated around Z
* 5). translated to its final position
*
* The combined Euler rotation is 'R = Rz * Ry * Rx' using the following abbreviations:
* 'cx, sx' - cosine and sine of rotation X
* 'cy, sy' - cosine and sine of rotation Y
* 'cz, sz' - cosine and sine of rotation Z
*
* The resulting 3x3 rotation part is:
*
* [ cz*cy cz*sy*sx - sz*cx cz*sy*cx + sz*sx ]
* [ sz*cy sz*sy*sx + cz*cx sz*sy*cx - cz*sx ]
* [ -sy cy*sx cy*cx ]
*
* Each rotation column is multiplied by the corresponding scale value and
* translation is stored in the last column, producing this affine 4x4 matrix:
*
* column 0: [ (rotation00 * scaleX), (rotation10 * scaleX), (rotation20 * scaleX) , 0 ]
* column 1: [ (rotation01 * scaleY), (rotation11 * scaleY), (rotation21 * scaleY) , 0 ]
* column 2: [ (rotation02 * scaleZ), (rotation12 * scaleZ), (rotation22 * scaleZ) , 0 ]
* column 3: [ translateX, translateY, translateZ, 1 ]
*
* Writes directly to the output buffer without intermediate matrix allocations.
*
* @param {Float32Array} out - Output 4x4 matrix buffer.
* @param {number} translateX - Translation along X axis.
* @param {number} translateY - Translation along Y axis.
* @param {number} translateZ - Translation along Z axis.
* @param {number} rotationX - Rotation around X axis in radians.
* @param {number} rotationY - Rotation around Y axis in radians.
* @param {number} rotationZ - Rotation around Z axis in radians.
* @param {number} scaleX - Scale along X axis.
* @param {number} scaleY - Scale along Y axis.
* @param {number} scaleZ - Scale along Z axis.
* @returns {Float32Array} - The provided output buffer.
* @throws {TypeError} - If the output buffer or arguments are invalid.
*/
static writeTransformationTo(
out,
translateX,
translateY,
translateZ,
rotationX,
rotationY,
rotationZ,
scaleX,
scaleY,
scaleZ
) {
if (!(out instanceof Float32Array) || out.length !== MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT) {
throw new TypeError('`Matrix4.writeTransformationTo` expects out to be `Float32Array(16)`.');
}
if (typeof translateX !== 'number'
|| typeof translateY !== 'number'
|| typeof translateZ !== 'number'
|| typeof rotationX !== 'number'
|| typeof rotationY !== 'number'
|| typeof rotationZ !== 'number'
|| typeof scaleX !== 'number'
|| typeof scaleY !== 'number'
|| typeof scaleZ !== 'number') {
throw new TypeError('`Matrix4.writeTransformationTo` expects numeric arguments.');
}
const cosX = Math.cos(rotationX);
const sinX = Math.sin(rotationX);
const cosY = Math.cos(rotationY);
const sinY = Math.sin(rotationY);
const cosZ = Math.cos(rotationZ);
const sinZ = Math.sin(rotationZ);
const rotation00 = cosZ * cosY;
const rotation01 = (cosZ * sinY * sinX) - (sinZ * cosX);
const rotation02 = (cosZ * sinY * cosX) + (sinZ * sinX);
const rotation10 = sinZ * cosY;
const rotation11 = (sinZ * sinY * sinX) + (cosZ * cosX);
const rotation12 = (sinZ * sinY * cosX) - (cosZ * sinX);
const rotation20 = -sinY;
const rotation21 = cosY * sinX;
const rotation22 = cosY * cosX;
// X axis:
out[0] = rotation00 * scaleX;
out[1] = rotation10 * scaleX;
out[2] = rotation20 * scaleX;
out[3] = MathConstants.MATH_MATRIX_VALUES.ZERO;
// Y axis:
out[4] = rotation01 * scaleY;
out[5] = rotation11 * scaleY;
out[6] = rotation21 * scaleY;
out[7] = MathConstants.MATH_MATRIX_VALUES.ZERO;
// Z axis:
out[8] = rotation02 * scaleZ;
out[9] = rotation12 * scaleZ;
out[10] = rotation22 * scaleZ;
out[11] = MathConstants.MATH_MATRIX_VALUES.ZERO;
// Translation:
out[12] = translateX;
out[13] = translateY;
out[14] = translateZ;
out[15] = MathConstants.MATH_MATRIX_VALUES.UNIT;
return out;
}
/**
* Multiplies two 4x4 matrices: 'result = leftMatrix * rightMatrix'.
*
* Order matters. With column-vector style, right matrix is applied first.
*
* @param {Float32Array} leftMatrix - Left-hand matrix (4x4).
* @param {Float32Array} rightMatrix - Right-hand matrix (4x4).
* @returns {Float32Array} - A new matrix containing the product.
*/
static multiply(leftMatrix, rightMatrix) {
if (!(leftMatrix instanceof Float32Array)
|| leftMatrix.length !== MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT
|| !(rightMatrix instanceof Float32Array)
|| rightMatrix.length !== MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT) {
throw new TypeError('`Matrix4.multiply` expects two 4x4 `Float32Array` matrices.');
}
return Matrix4.#multiplyIntoUnchecked(Matrix4.#createEmpty(), leftMatrix, rightMatrix);
}
/**
* Multiplies two 4x4 matrices into an existing output matrix:
* 'out = leftMatrix * rightMatrix'.
*
* Notes:
* - order matters, with column-vector style the right matrix is applied first
* - out must not be the same object as 'leftMatrix' or 'rightMatrix'
*
* @param {Float32Array} out - Output 4x4 matrix.
* @param {Float32Array} leftMatrix - Left-hand matrix (4x4).
* @param {Float32Array} rightMatrix - Right-hand matrix (4x4).
* @returns {Float32Array} - The output matrix (out).
*/
static multiplyTo(out, leftMatrix, rightMatrix) {
if (!(out instanceof Float32Array)
|| out.length !== MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT
|| !(leftMatrix instanceof Float32Array)
|| leftMatrix.length !== MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT
|| !(rightMatrix instanceof Float32Array)
|| rightMatrix.length !== MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT) {
throw new TypeError('`Matrix4.multiplyTo` expects three 4x4 `Float32Array` matrices.');
}
if (out === leftMatrix || out === rightMatrix) {
throw new Error('`Matrix4.multiplyTo` does not support in-place multiplication. Use a separate output matrix.');
}
return Matrix4.#multiplyIntoUnchecked(out, leftMatrix, rightMatrix);
}
/**
* Multiplies several matrices in sequence:
* 'result = m0 * m1 * m2 * ... * mn'
*
* Notes:
* - order matters, with column-vector style, the last matrix is applied first
* - if no matrices are provided, returns a new identity matrix
* - if exactly one matrix is provided, returns the same matrix instance (no copy)
*
* @param {...Float32Array} matrices - Matrices to multiply, in order.
* @returns {Float32Array} - The resulting matrix.
*/
static multiplyMany(...matrices) {
if (matrices.length === 0) {
return Matrix4.createIdentity();
}
for (const matrix of matrices) {
if (!(matrix instanceof Float32Array)
|| matrix.length !== MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT) {
throw new TypeError('`Matrix4.multiplyMany` expects the 4x4 `Float32Array` matrices.');
}
}
let result = matrices[0];
for (let index = 1; index < matrices.length; index += 1) {
result = Matrix4.multiply(result, matrices[index]);
}
return result;
}
/**
* Transposes a 4x4 matrix by swapping its rows and columns.
*
* @param {Float32Array} matrix - Input 4x4 matrix.
* @returns {Float32Array} - A new transposed matrix.
*/
static transpose(matrix) {
if (!(matrix instanceof Float32Array) || matrix.length !== MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT) {
throw new TypeError('`Matrix4.transpose` expects a 4x4 `Float32Array` matrix.');
}
return Matrix4.#transposeIntoUnchecked(Matrix4.#createEmpty(), matrix);
}
/**
* Transposes a 4x4 matrix into an existing output matrix by swapping rows and columns.
*
* Note: out must not be the same object as matrix.
*
* @param {Float32Array} out - Output 4x4 matrix.
* @param {Float32Array} matrix - Input 4x4 matrix.
* @returns {Float32Array} - The output matrix (out).
*/
static transposeTo(out, matrix) {
if (!(out instanceof Float32Array)
|| out.length !== MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT
|| !(matrix instanceof Float32Array)
|| matrix.length !== MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT) {
throw new TypeError('`Matrix4.transposeTo` expects two 4x4 `Float32Array` matrices.');
}
if (out === matrix) {
throw new Error('`Matrix4.transposeTo` does not support in-place transpose. Use a separate output matrix.');
}
return Matrix4.#transposeIntoUnchecked(out, matrix);
}
/**
* Inverts a 4x4 matrix using its adjugate and determinant.
*
* A near-zero determinant means the matrix is singular or unsafe to invert.
*
* @param {Float32Array} matrix - Input 4x4 matrix.
* @returns {Float32Array} - A new inverted matrix.
*/
static invert(matrix) {
if (!(matrix instanceof Float32Array) || matrix.length !== MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT) {
throw new TypeError('`Matrix4.invert` expects a 4x4 `Float32Array` matrix.');
}
return Matrix4.#invertIntoUnchecked(Matrix4.#createEmpty(), matrix);
}
/**
* Inverts a 4x4 matrix into an existing output matrix using its adjugate and determinant.
*
* A near-zero determinant means the matrix is singular or unsafe to invert.
*
* @param {Float32Array} out - Output 4x4 matrix.
* @param {Float32Array} matrix - Input 4x4 matrix.
* @returns {Float32Array} - The output matrix (out).
*/
static invertTo(out, matrix) {
if (!(out instanceof Float32Array)
|| out.length !== MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT
|| !(matrix instanceof Float32Array)
|| matrix.length !== MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT) {
throw new TypeError('`Matrix4.invertTo` expects two 4x4 `Float32Array` matrices.');
}
if (out === matrix) {
throw new Error('`Matrix4.invertTo` does not support in-place inversion.');
}
return Matrix4.#invertIntoUnchecked(out, matrix);
}
/**
* Multiplies two validated 4x4 matrices into out.
*
* Assumes public validation already checked matrix types, sizes, and output aliasing.
*
* @param {Float32Array} out - Output 4x4 matrix that will receive the result.
* @param {Float32Array} leftMatrix - Left-hand 4x4 matrix.
* @param {Float32Array} rightMatrix - Right-hand 4x4 matrix.
* @returns {Float32Array} - The output matrix (out).
* @private
*/
static #multiplyIntoUnchecked(out, leftMatrix, rightMatrix) {
for (let columnIndex = 0; columnIndex < MathConstants.MATH_LAYOUT.MATRIX_COLUMN_COUNT; columnIndex += 1) {
const rightColumnOffset = columnIndex * MathConstants.MATH_LAYOUT.MATRIX_STRIDE;
for (let rowIndex = 0; rowIndex < MathConstants.MATH_LAYOUT.MATRIX_ROW_COUNT; rowIndex += 1) {
const resultIndex = rightColumnOffset + rowIndex;
out[resultIndex] =
leftMatrix[0 * MathConstants.MATH_LAYOUT.MATRIX_STRIDE + rowIndex] * rightMatrix[rightColumnOffset + 0]
+ leftMatrix[1 * MathConstants.MATH_LAYOUT.MATRIX_STRIDE + rowIndex] * rightMatrix[rightColumnOffset + 1]
+ leftMatrix[2 * MathConstants.MATH_LAYOUT.MATRIX_STRIDE + rowIndex] * rightMatrix[rightColumnOffset + 2]
+ leftMatrix[3 * MathConstants.MATH_LAYOUT.MATRIX_STRIDE + rowIndex] * rightMatrix[rightColumnOffset + 3];
}
}
return out;
}
/**
* Writes a perspective projection matrix to the provided output buffer.
*
* Assumes public method validation already checked the output buffer and projection parameters.
*
* @param {Float32Array} out - Output 4x4 matrix.
* @param {number} fieldOfViewRadians - Vertical field of view in radians.
* @param {number} aspectRatio - Viewport width-to-height ratio.
* @param {number} near - Near clipping distance.
* @param {number} far - Far clipping distance.
* @returns {Float32Array} - The output matrix (out).
* @private
*/
static #writePerspectiveIntoUnchecked(out, fieldOfViewRadians, aspectRatio, near, far) {
const inverseDepthRange =
MathConstants.MATH_PERSPECTIVE.DEPTH_RANGE_NUMERATOR
/ (near - far);
const halfFieldOfViewRadians =
fieldOfViewRadians
/ MathConstants.MATH_PERSPECTIVE.HALF_FIELD_OF_VIEW_DIVISOR;
const projectionScale =
MathConstants.MATH_PERSPECTIVE.PROJECTION_SCALE_NUMERATOR
/ Math.tan(halfFieldOfViewRadians);
const horizontalProjectionScale = Math.fround(projectionScale / aspectRatio);
const verticalProjectionScale = Math.fround(projectionScale);
const depthScale = Math.fround((far + near) * inverseDepthRange);
const depthOffset = Math.fround(
(MathConstants.MATH_PERSPECTIVE.Z_RANGE_MULTIPLIER * far * near) * inverseDepthRange
);
if (!Number.isFinite(horizontalProjectionScale)
|| !Number.isFinite(verticalProjectionScale)
|| !Number.isFinite(depthScale)
|| !Number.isFinite(depthOffset)) {
throw new RangeError('Perspective projection values must remain finite after conversion to `Float32Array`.');
}
out[0] = horizontalProjectionScale;
out[1] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[2] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[3] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[4] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[5] = verticalProjectionScale;
out[6] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[7] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[8] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[9] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[10] = depthScale;
out[11] = MathConstants.MATH_PERSPECTIVE.W_COMPONENT_SCALE;
out[12] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[13] = MathConstants.MATH_MATRIX_VALUES.ZERO;
out[14] = depthOffset;
out[15] = MathConstants.MATH_MATRIX_VALUES.ZERO;
return out;
}
/**
* Transposes a validated 4x4 matrix into out.
*
* Assumes public validation already checked matrix types, sizes, and output aliasing.
*
* @param {Float32Array} out - Output 4x4 matrix, that will receive the result.
* @param {Float32Array} matrix - Input 4x4 matrix.
* @returns {Float32Array} - The output matrix (out).
* @private
*/
static #transposeIntoUnchecked(out, matrix) {
out[0] = matrix[0];
out[1] = matrix[4];
out[2] = matrix[8];
out[3] = matrix[12];
out[4] = matrix[1];
out[5] = matrix[5];
out[6] = matrix[9];
out[7] = matrix[13];
out[8] = matrix[2];
out[9] = matrix[6];
out[10] = matrix[10];
out[11] = matrix[14];
out[12] = matrix[3];
out[13] = matrix[7];
out[14] = matrix[11];
out[15] = matrix[15];
return out;
}
/**
* Inverts a validated 4x4 matrix into out.
*
* Uses the adjugate and determinant, throws when the determinant is too close to zero.
* Assumes public validation already checked matrix types, sizes, and output aliasing.
*
* @param {Float32Array} out - Output 4x4 matrix, that will receive the result.
* @param {Float32Array} matrix - Input 4x4 matrix.
* @returns {Float32Array} - The output matrix (out).
* @private
*/
static #invertIntoUnchecked(out, matrix) {
const a00 = matrix[0];
const a01 = matrix[1];
const a02 = matrix[2];
const a03 = matrix[3];
const a10 = matrix[4];
const a11 = matrix[5];
const a12 = matrix[6];
const a13 = matrix[7];
const a20 = matrix[8];
const a21 = matrix[9];
const a22 = matrix[10];
const a23 = matrix[11];
const a30 = matrix[12];
const a31 = matrix[13];
const a32 = matrix[14];
const a33 = matrix[15];
const b00 = a00 * a11 - a01 * a10;
const b01 = a00 * a12 - a02 * a10;
const b02 = a00 * a13 - a03 * a10;
const b03 = a01 * a12 - a02 * a11;
const b04 = a01 * a13 - a03 * a11;
const b05 = a02 * a13 - a03 * a12;
const b06 = a20 * a31 - a21 * a30;
const b07 = a20 * a32 - a22 * a30;
const b08 = a20 * a33 - a23 * a30;
const b09 = a21 * a32 - a22 * a31;
const b10 = a21 * a33 - a23 * a31;
const b11 = a22 * a33 - a23 * a32;
const determinant =
b00 * b11
- b01 * b10
+ b02 * b09
+ b03 * b08
- b04 * b07
+ b05 * b06;
if (Math.abs(determinant) < MathConstants.MATH_MATRIX_INVERSION.MIN_INVERTIBLE_DETERMINANT_ABS) {
throw new Error('`Matrix4.#invertIntoUnchecked` matrix is not invertible.');
}
const inverseDeterminant = MathConstants.MATH_MATRIX_INVERSION.INVERSE_DETERMINANT_NUMERATOR / determinant;
out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * inverseDeterminant;
out[1] = (a02 * b10 - a01 * b11 - a03 * b09) * inverseDeterminant;
out[2] = (a31 * b05 - a32 * b04 + a33 * b03) * inverseDeterminant;
out[3] = (a22 * b04 - a21 * b05 - a23 * b03) * inverseDeterminant;
out[4] = (a12 * b08 - a10 * b11 - a13 * b07) * inverseDeterminant;
out[5] = (a00 * b11 - a02 * b08 + a03 * b07) * inverseDeterminant;
out[6] = (a32 * b02 - a30 * b05 - a33 * b01) * inverseDeterminant;
out[7] = (a20 * b05 - a22 * b02 + a23 * b01) * inverseDeterminant;
out[8] = (a10 * b10 - a11 * b08 + a13 * b06) * inverseDeterminant;
out[9] = (a01 * b08 - a00 * b10 - a03 * b06) * inverseDeterminant;
out[10] = (a30 * b04 - a31 * b02 + a33 * b00) * inverseDeterminant;
out[11] = (a21 * b02 - a20 * b04 - a23 * b00) * inverseDeterminant;
out[12] = (a11 * b07 - a10 * b09 - a12 * b06) * inverseDeterminant;
out[13] = (a00 * b09 - a01 * b07 + a02 * b06) * inverseDeterminant;
out[14] = (a31 * b01 - a30 * b03 - a32 * b00) * inverseDeterminant;
out[15] = (a20 * b03 - a21 * b01 + a22 * b00) * inverseDeterminant;
return out;
}
/**
* Internal helper to create a zero-filled 4x4 matrix.
*
* @returns {Float32Array} - A new zero-filled 4x4 matrix (length 16).
* @private
*/
static #createEmpty() {
return new Float32Array(MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT);
}
}