@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
479 lines (396 loc) • 13.2 kB
JavaScript
import { assert } from "../../../core/assert.js";
import { allocate_m4 } from "../../../core/geom/3d/mat4/allocate_m4.js";
import { compose_matrix4_array } from "../../../core/geom/3d/mat4/compose_matrix4_array.js";
import { decompose_matrix_4_array } from "../../../core/geom/3d/mat4/decompose_matrix_4_array.js";
import { M4_IDENTITY } from "../../../core/geom/3d/mat4/M4_IDENTITY.js";
import { m4_multiply } from "../../../core/geom/3d/mat4/m4_multiply.js";
import Quaternion from "../../../core/geom/Quaternion.js";
import Vector3 from "../../../core/geom/Vector3.js";
import { TransformFlags } from "./TransformFlags.js";
/**
*
* @type {Float32Array}
*/
const scratch_matrix = new Float32Array(16);
/**
* Default set of flags for any {@link Transform}
* @type {TransformFlags|number}
*/
const FLAGS_DEFAULT = TransformFlags.AutomaticChangeDetection;
/**
* A Transform represents the position, rotation, and scale of an object in 3D space.
* Think of it like the object's location, orientation, and size.
* It has properties for {@link position} (like coordinates), {@link rotation} (how it's oriented), and {@link scale} (how big it is).
*
* @example
* const t = new Transform();
* t.position.set(1,2,3);
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class Transform {
/**
* @type {Vector3}
* @readonly
*/
position = new Vector3(0, 0, 0);
/**
* Orientation represented as a quaternion.
* If Euler (XYZ) angles are required - consult {@link Quaternion} for relevant method
* @type {Quaternion}
* @readonly
*/
rotation = new Quaternion(0, 0, 0, 1);
/**
* @type {Vector3}
* @readonly
*/
scale = new Vector3(1, 1, 1);
/**
* Affine transform matrix, {@link position}, {@link rotation}, and {@link scale} must match, but shear can be different.
* Note: this is managed by the {@link Transform} itself unless {@link TransformFlags.AutomaticChangeDetection} is disabled; do not modify the matrix directly.
* Generally, if you want to modify it - use {@link fromMatrix} method instead.
*
* If you're not comfortable with matrices, just leave defaults on and stick to primary attributes i.e. {@link position}, {@link rotation}, and {@link scale}.
* @readonly
* @type {Float32Array}
*/
matrix = allocate_m4();
/**
* Various bit flags, see {@link TransformFlags}
* This should generally be accessed through {@link getFlag}/{@link setFlag} instead of modifying the value directly
* @type {number}
*/
flags = FLAGS_DEFAULT;
constructor() {
// Watch changes.
// This creates a data dependency, which makes GC impossible if we keep references to position/rotation/scale
// Please clone those properties if you need to keep them past the expiry of the Transform
this.subscribe(this.#handle_component_change, this);
}
/**
* Current "forward" direction
* NOTE that this vector is not live, meaning that if you modify transform, previously-obtained result will no longer be valid
* @returns {Vector3}
*/
get forward() {
const result = Vector3.forward.clone();
result.applyDirectionMatrix4(this.matrix);
return result;
}
/**
* Current "up" direction
* @return {Vector3}
*/
get up() {
const result = Vector3.up.clone();
result.applyDirectionMatrix4(this.matrix);
return result;
}
/**
* Current "right" direction
* @return {Vector3}
*/
get right() {
const result = Vector3.right.clone();
result.applyDirectionMatrix4(this.matrix);
return result;
}
/**
* Attach change listener
* @param {function} handler
* @param {*} [thisArg]
*/
subscribe(handler, thisArg) {
this.position.onChanged.add(handler, thisArg);
this.rotation.onChanged.add(handler, thisArg);
this.scale.onChanged.add(handler, thisArg);
}
/**
* Disconnect change listener
* @param {function} handler
* @param {*} [thisArg]
*/
unsubscribe(handler, thisArg) {
this.position.onChanged.remove(handler, thisArg);
this.rotation.onChanged.remove(handler, thisArg);
this.scale.onChanged.remove(handler, thisArg);
}
/**
*
* @private
*/
#handle_component_change() {
if (this.getFlag(TransformFlags.AutomaticChangeDetection)) {
this.updateMatrix();
}
}
/**
*
* @param {number|TransformFlags} flag
* @returns {void}
*/
setFlag(flag) {
this.flags |= flag;
}
/**
*
* @param {number|TransformFlags} flag
* @returns {void}
*/
clearFlag(flag) {
this.flags &= ~flag;
}
/**
*
* @param {number|TransformFlags} flag
* @param {boolean} value
*/
writeFlag(flag, value) {
if (value) {
this.setFlag(flag);
} else {
this.clearFlag(flag);
}
}
/**
*
* @param {number|TransformFlags} flag
* @returns {boolean}
*/
getFlag(flag) {
return (this.flags & flag) === flag;
}
/**
* Update {@link matrix} attribute from current {@link position}/{@link rotation}/{@link scale}
*
* Useful for when {@link TransformFlags.AutomaticChangeDetection} is disabled.
*/
updateMatrix() {
compose_matrix4_array(this.matrix, this.position, this.rotation, this.scale);
}
/**
*
* @param {Vector3} target
* @param {Vector3} [up]
*/
lookAt(target, up = Vector3.up) {
const position = this.position;
const delta_x = target.x - position.x;
const delta_y = target.y - position.y;
const delta_z = target.z - position.z;
if (delta_x === 0 && delta_y === 0 && delta_z === 0) {
// Target is at the same location as this transform, no valid rotation, keep whatever we have.
// Generally this is a programmer mistake, making any changes to the rotation would be undesirable.
// The most valid option is to keep the current state.
return;
}
this.rotation._lookRotation(
delta_x, delta_y, delta_z,
up.x, up.y, up.z
);
}
fromJSON(json) {
const jp = json.position;
if (jp !== undefined) {
this.position.fromJSON(jp);
} else {
this.position.copy(Vector3.zero);
}
const jr = json.rotation;
if (jr !== undefined) {
this.rotation.fromJSON(jr);
} else {
this.rotation.copy(Quaternion.identity);
}
const js = json.scale;
if (js !== undefined) {
this.scale.fromJSON(js);
} else {
this.scale.copy(Vector3.one);
}
}
toJSON() {
return {
position: this.position.toJSON(),
rotation: this.rotation.toJSON(),
scale: this.scale.toJSON()
};
}
/**
* Set this transform to match `other`.
* Afterward `this.equals(other) === true`.
* The `other` is unchanged in the process.
* @param {Transform} other
*/
copy(other) {
// prevent the matrix from being overridden
this.clearFlag(TransformFlags.AutomaticChangeDetection);
this.matrix.set(other.matrix);
this.position.copy(other.position);
this.rotation.copy(other.rotation);
this.scale.copy(other.scale);
assert.arrayEqual(this.matrix, other.matrix, 'matrices must be equal after copy');
this.flags = other.flags;
}
/**
*
* @returns {Transform}
*/
clone() {
const clone = new Transform();
clone.copy(this);
return clone;
}
/**
* Strict equality check
* @param {Transform} other
* @returns {boolean}
*/
equals(other) {
return this.position.equals(other.position)
&& this.rotation.equals(other.rotation)
&& this.scale.equals(other.scale);
}
/**
*
* @returns {number}
*/
hash() {
// only use position for hash, for speed, since in most applications position will vary the most
return this.position.hash();
}
/**
*
* @param json
* @returns {Transform}
*/
static fromJSON(json) {
const result = new Transform();
result.fromJSON(json);
return result;
}
/**
*
* @param {number[]|Float32Array} mat
* @returns {Transform}
*/
static fromMatrix(mat) {
const result = new Transform();
result.fromMatrix(mat);
return result;
}
/**
* This is a post-multiplication, `this = this * other`.
* The result of the multiplication is written into this transform.
*
* @param {Transform} other
* @returns {this}
*/
multiply(other) {
return this.multiplyTransforms(this, other);
}
/**
* Multiply two transforms, result it written into this one
* @param {Transform} a
* @param {Transform} b
* @returns {this}
*/
multiplyTransforms(a, b) {
assert.defined(a, 'a');
assert.defined(b, 'b');
assert.equal(a.isTransform, true, 'a.isTransform !== true');
assert.equal(b.isTransform, true, 'b.isTransform !== true');
m4_multiply(scratch_matrix, a.matrix, b.matrix);
return this.fromMatrix(scratch_matrix);
}
/**
*
* @param {mat4|number[]|Float32Array} matrix 4x4 affine matrix
* @returns {this}
*/
fromMatrix(matrix) {
assert.isArrayLike(matrix, 'matrix');
assert.greaterThanOrEqual(matrix.length, 16, 'matrix.length must be >= 16');
// we know we are changing the matrix, so we're going to need to disable the flag that sets matrix from position/rotation/scale changes
const ad = this.getFlag(TransformFlags.AutomaticChangeDetection);
this.clearFlag(TransformFlags.AutomaticChangeDetection);
this.matrix.set(matrix);
decompose_matrix_4_array(matrix, this.position, this.rotation, this.scale);
// restore value of the flag
this.writeFlag(TransformFlags.AutomaticChangeDetection, ad);
return this;
}
/**
* Write out the current transform to a supplied container
* @param {number[]|Float32Array} [result] if not provided, `Float32Array` of size 16 will be created
* @returns {number[]} same as the input
*/
toMatrix(result = new Float32Array(16)) {
compose_matrix4_array(result, this.position, this.rotation, this.scale);
return result;
}
/**
* reset transform, resulting transform is an identity matrix
* - position: [0,0,0]
* - rotation: [0,0,0,1]
* - scale: [1,1,1]
*/
makeIdentity() {
this.fromMatrix(M4_IDENTITY);
}
/**
* Identity Transform is where position is 0, rotation is 0 (un-rotated) and scale is 1
* @returns {boolean}
*/
isIdentity() {
return this.position.equals(Vector3.zero)
&& this.rotation.equals(Quaternion.identity)
&& this.scale.equals(Vector3.one);
}
toString() {
return `Transform{ position: ${this.position}, rotation: ${this.rotation}, scale: ${this.scale} }`;
}
/**
* @deprecated use {@link Quaternion.rotateTowards} instead
* @param {Quaternion} sourceQuaternion
* @param {Vector3} targetVector
* @param {Number} limit
*/
static adjustRotation(
sourceQuaternion,
targetVector,
limit = Infinity
) {
console.warn('deprecated, use Transform.rotation.rotateTowards instead');
const q = new Quaternion();
q.lookRotation(targetVector);
sourceQuaternion.rotateTowards(q, limit);
}
}
/**
* @readonly
* @type {string}
*/
Transform.typeName = "Transform";
/**
* Enables a fast type check, without having to import the class separately.
* @readonly
* @type {boolean}
* @example
* if(t.isTransform){
* // t is a Transform, do stuff
* }
*/
Transform.prototype.isTransform = true;
/**
* @readonly
* @deprecated use {@link Transform.prototype.toMatrix} instead
*/
Transform.prototype.toMatrix4 = Transform.prototype.toMatrix
/**
* @readonly
* @deprecated use {@link Transform.prototype.fromMatrix} instead
*/
Transform.prototype.fromMatrix4 = Transform.prototype.fromMatrix