@babylonjs/core
Version:
Getting started? Play directly with the Babylon.js API using our [playground](https://playground.babylonjs.com/). It also contains a lot of samples to learn how to use it.
381 lines • 16.7 kB
JavaScript
import { Matrix, Quaternion, Vector3 } from "../../Maths/math.vector.js";
import { Clamp } from "../../Maths/math.scalar.functions.js";
import { Epsilon } from "../../Maths/math.constants.js";
/**
* A behavior that when attached to a mesh will follow a camera
* @since 5.0.0
*/
export class FollowBehavior {
constructor() {
// Memory cache to avoid GC usage
this._tmpQuaternion = new Quaternion();
this._tmpVectors = [new Vector3(), new Vector3(), new Vector3(), new Vector3(), new Vector3(), new Vector3(), new Vector3()];
this._tmpMatrix = new Matrix();
this._tmpInvertView = new Matrix();
this._tmpForward = new Vector3();
this._tmpNodeForward = new Vector3();
this._tmpPosition = new Vector3();
this._workingPosition = new Vector3();
this._workingQuaternion = new Quaternion();
this._lastTick = -1;
this._recenterNextUpdate = true;
/**
* Set to false if the node should strictly follow the camera without any interpolation time
*/
this.interpolatePose = true;
/**
* Rate of interpolation of position and rotation of the attached node.
* Higher values will give a slower interpolation.
*/
this.lerpTime = 500;
/**
* If the behavior should ignore the pitch and roll of the camera.
*/
this.ignoreCameraPitchAndRoll = false;
/**
* Pitch offset from camera (relative to Max Distance)
* Is only effective if `ignoreCameraPitchAndRoll` is set to `true`.
*/
this.pitchOffset = 15;
/**
* The vertical angle from the camera forward axis to the owner will not exceed this value
*/
this.maxViewVerticalDegrees = 30;
/**
* The horizontal angle from the camera forward axis to the owner will not exceed this value
*/
this.maxViewHorizontalDegrees = 30;
/**
* The attached node will not reorient until the angle between its forward vector and the vector to the camera is greater than this value
*/
this.orientToCameraDeadzoneDegrees = 60;
/**
* Option to ignore distance clamping
*/
this.ignoreDistanceClamp = false;
/**
* Option to ignore angle clamping
*/
this.ignoreAngleClamp = false;
/**
* Max vertical distance between the attachedNode and camera
*/
this.verticalMaxDistance = 0;
/**
* Default distance from eye to attached node, i.e. the sphere radius
*/
this.defaultDistance = 0.8;
/**
* Max distance from eye to attached node, i.e. the sphere radius
*/
this.maximumDistance = 2;
/**
* Min distance from eye to attached node, i.e. the sphere radius
*/
this.minimumDistance = 0.3;
/**
* Ignore vertical movement and lock the Y position of the object.
*/
this.useFixedVerticalOffset = false;
/**
* Fixed vertical position offset distance.
*/
this.fixedVerticalOffset = 0;
/**
* Enables/disables the behavior
* @internal
*/
this._enabled = true;
}
/**
* The camera that should be followed by this behavior
*/
get followedCamera() {
return this._followedCamera || this._scene.activeCamera;
}
set followedCamera(camera) {
this._followedCamera = camera;
}
/**
* The name of the behavior
*/
get name() {
return "Follow";
}
/**
* Initializes the behavior
*/
init() { }
/**
* Attaches the follow behavior
* @param ownerNode The mesh that will be following once attached
* @param followedCamera The camera that should be followed by the node
*/
attach(ownerNode, followedCamera) {
this._scene = ownerNode.getScene();
this.attachedNode = ownerNode;
if (followedCamera) {
this.followedCamera = followedCamera;
}
this._addObservables();
}
/**
* Detaches the behavior from the mesh
*/
detach() {
this.attachedNode = null;
this._removeObservables();
}
/**
* Recenters the attached node in front of the camera on the next update
*/
recenter() {
this._recenterNextUpdate = true;
}
_angleBetweenVectorAndPlane(vector, normal) {
// Work on copies
this._tmpVectors[0].copyFrom(vector);
vector = this._tmpVectors[0];
this._tmpVectors[1].copyFrom(normal);
normal = this._tmpVectors[1];
vector.normalize();
normal.normalize();
return Math.PI / 2 - Math.acos(Vector3.Dot(vector, normal));
}
_length2D(vector) {
return Math.sqrt(vector.x * vector.x + vector.z * vector.z);
}
_distanceClamp(currentToTarget, moveToDefault = false) {
let minDistance = this.minimumDistance;
let maxDistance = this.maximumDistance;
const defaultDistance = this.defaultDistance;
const direction = this._tmpVectors[0];
direction.copyFrom(currentToTarget);
let currentDistance = direction.length();
direction.normalizeFromLength(currentDistance);
if (this.ignoreCameraPitchAndRoll) {
// If we don't account for pitch offset, the casted object will float up/down as the reference
// gets closer to it because we will still be casting in the direction of the pitched offset.
// To fix this, only modify the XZ position of the object.
minDistance = this._length2D(direction) * minDistance;
maxDistance = this._length2D(direction) * maxDistance;
const currentDistance2D = this._length2D(currentToTarget);
direction.scaleInPlace(currentDistance / currentDistance2D);
currentDistance = currentDistance2D;
}
let clampedDistance = currentDistance;
if (moveToDefault) {
clampedDistance = defaultDistance;
}
else {
clampedDistance = Clamp(currentDistance, minDistance, maxDistance);
}
currentToTarget.copyFrom(direction).scaleInPlace(clampedDistance);
return currentDistance !== clampedDistance;
}
_applyVerticalClamp(currentToTarget) {
if (this.verticalMaxDistance !== 0) {
currentToTarget.y = Clamp(currentToTarget.y, -this.verticalMaxDistance, this.verticalMaxDistance);
}
}
_toOrientationQuatToRef(vector, quaternion) {
Quaternion.RotationYawPitchRollToRef(Math.atan2(vector.x, vector.z), Math.atan2(vector.y, Math.sqrt(vector.z * vector.z + vector.x * vector.x)), 0, quaternion);
}
_applyPitchOffset(invertView) {
const forward = this._tmpVectors[0];
const right = this._tmpVectors[1];
forward.copyFromFloats(0, 0, this._scene.useRightHandedSystem ? -1 : 1);
right.copyFromFloats(1, 0, 0);
Vector3.TransformNormalToRef(forward, invertView, forward);
forward.y = 0;
forward.normalize();
Vector3.TransformNormalToRef(right, invertView, right);
Quaternion.RotationAxisToRef(right, (this.pitchOffset * Math.PI) / 180, this._tmpQuaternion);
forward.rotateByQuaternionToRef(this._tmpQuaternion, forward);
this._toOrientationQuatToRef(forward, this._tmpQuaternion);
this._tmpQuaternion.toRotationMatrix(this._tmpMatrix);
// Since we already extracted position from the invert view matrix, we can
// disregard the position part of the matrix in the copy
invertView.copyFrom(this._tmpMatrix);
}
_angularClamp(invertView, currentToTarget) {
const forward = this._tmpVectors[5];
forward.copyFromFloats(0, 0, this._scene.useRightHandedSystem ? -1 : 1);
const right = this._tmpVectors[6];
right.copyFromFloats(1, 0, 0);
// forward and right are related to camera frame of reference
Vector3.TransformNormalToRef(forward, invertView, forward);
Vector3.TransformNormalToRef(right, invertView, right);
// Up is global Z
const up = Vector3.UpReadOnly;
const dist = currentToTarget.length();
if (dist < Epsilon) {
return false;
}
let angularClamped = false;
const rotationQuat = this._tmpQuaternion;
// X-axis leashing
if (this.ignoreCameraPitchAndRoll) {
const angle = Vector3.GetAngleBetweenVectorsOnPlane(currentToTarget, forward, right);
Quaternion.RotationAxisToRef(right, angle, rotationQuat);
currentToTarget.rotateByQuaternionToRef(rotationQuat, currentToTarget);
}
else {
const angle = -Vector3.GetAngleBetweenVectorsOnPlane(currentToTarget, forward, right);
const minMaxAngle = ((this.maxViewVerticalDegrees * Math.PI) / 180) * 0.5;
if (angle < -minMaxAngle) {
Quaternion.RotationAxisToRef(right, -angle - minMaxAngle, rotationQuat);
currentToTarget.rotateByQuaternionToRef(rotationQuat, currentToTarget);
angularClamped = true;
}
else if (angle > minMaxAngle) {
Quaternion.RotationAxisToRef(right, -angle + minMaxAngle, rotationQuat);
currentToTarget.rotateByQuaternionToRef(rotationQuat, currentToTarget);
angularClamped = true;
}
}
// Y-axis leashing
const angle = this._angleBetweenVectorAndPlane(currentToTarget, right) * (this._scene.useRightHandedSystem ? -1 : 1);
const minMaxAngle = ((this.maxViewHorizontalDegrees * Math.PI) / 180) * 0.5;
if (angle < -minMaxAngle) {
Quaternion.RotationAxisToRef(up, -angle - minMaxAngle, rotationQuat);
currentToTarget.rotateByQuaternionToRef(rotationQuat, currentToTarget);
angularClamped = true;
}
else if (angle > minMaxAngle) {
Quaternion.RotationAxisToRef(up, -angle + minMaxAngle, rotationQuat);
currentToTarget.rotateByQuaternionToRef(rotationQuat, currentToTarget);
angularClamped = true;
}
return angularClamped;
}
_orientationClamp(currentToTarget, rotationQuaternion) {
// Construct a rotation quat from up vector and target vector
const toFollowed = this._tmpVectors[0];
toFollowed.copyFrom(currentToTarget).scaleInPlace(-1).normalize();
const up = this._tmpVectors[1];
const right = this._tmpVectors[2];
// We use global up vector to orient the following node (global +Y)
up.copyFromFloats(0, 1, 0);
// Gram-Schmidt to create an orthonormal frame
Vector3.CrossToRef(toFollowed, up, right);
const length = right.length();
if (length < Epsilon) {
return;
}
right.normalizeFromLength(length);
Vector3.CrossToRef(right, toFollowed, up);
if (this.attachedNode?.getScene().useRightHandedSystem) {
Quaternion.FromLookDirectionRHToRef(toFollowed, up, rotationQuaternion);
}
else {
Quaternion.FromLookDirectionLHToRef(toFollowed, up, rotationQuaternion);
}
}
_passedOrientationDeadzone(currentToTarget, forward) {
const leashToFollow = this._tmpVectors[5];
leashToFollow.copyFrom(currentToTarget);
leashToFollow.normalize();
const angle = Math.abs(Vector3.GetAngleBetweenVectorsOnPlane(forward, leashToFollow, Vector3.UpReadOnly));
return (angle * 180) / Math.PI > this.orientToCameraDeadzoneDegrees;
}
_updateLeashing(camera) {
if (this.attachedNode && this._enabled) {
const oldParent = this.attachedNode.parent;
this.attachedNode.setParent(null);
const worldMatrix = this.attachedNode.getWorldMatrix();
const currentToTarget = this._workingPosition;
const rotationQuaternion = this._workingQuaternion;
const pivot = this.attachedNode.getPivotPoint();
const invertView = this._tmpInvertView;
invertView.copyFrom(camera.getViewMatrix());
invertView.invert();
Vector3.TransformCoordinatesToRef(pivot, worldMatrix, currentToTarget);
const position = this._tmpPosition;
position.copyFromFloats(0, 0, 0);
Vector3.TransformCoordinatesToRef(position, worldMatrix, position);
position.scaleInPlace(-1).subtractInPlace(pivot);
currentToTarget.subtractInPlace(camera.globalPosition);
if (this.ignoreCameraPitchAndRoll) {
this._applyPitchOffset(invertView);
}
let angularClamped = false;
const forward = this._tmpForward;
forward.copyFromFloats(0, 0, this._scene.useRightHandedSystem ? -1 : 1);
Vector3.TransformNormalToRef(forward, invertView, forward);
const nodeForward = this._tmpNodeForward;
nodeForward.copyFromFloats(0, 0, this._scene.useRightHandedSystem ? -1 : 1);
Vector3.TransformNormalToRef(nodeForward, worldMatrix, nodeForward);
if (this._recenterNextUpdate) {
currentToTarget.copyFrom(forward).scaleInPlace(this.defaultDistance);
}
else {
if (this.ignoreAngleClamp) {
const currentDistance = currentToTarget.length();
currentToTarget.copyFrom(forward).scaleInPlace(currentDistance);
}
else {
angularClamped = this._angularClamp(invertView, currentToTarget);
}
}
let distanceClamped = false;
if (!this.ignoreDistanceClamp) {
distanceClamped = this._distanceClamp(currentToTarget, angularClamped);
this._applyVerticalClamp(currentToTarget);
}
if (this.useFixedVerticalOffset) {
currentToTarget.y = position.y - camera.globalPosition.y + this.fixedVerticalOffset;
}
if (angularClamped || distanceClamped || this._passedOrientationDeadzone(currentToTarget, nodeForward) || this._recenterNextUpdate) {
this._orientationClamp(currentToTarget, rotationQuaternion);
}
this._workingPosition.subtractInPlace(pivot);
this._recenterNextUpdate = false;
this.attachedNode.setParent(oldParent);
}
}
_updateTransformToGoal(elapsed) {
if (!this.attachedNode || !this.followedCamera || !this._enabled) {
return;
}
if (!this.attachedNode.rotationQuaternion) {
this.attachedNode.rotationQuaternion = Quaternion.Identity();
}
const oldParent = this.attachedNode.parent;
this.attachedNode.setParent(null);
if (!this.interpolatePose) {
this.attachedNode.position.copyFrom(this.followedCamera.globalPosition).addInPlace(this._workingPosition);
this.attachedNode.rotationQuaternion.copyFrom(this._workingQuaternion);
return;
}
// position
const currentDirection = new Vector3();
currentDirection.copyFrom(this.attachedNode.position).subtractInPlace(this.followedCamera.globalPosition);
Vector3.SmoothToRef(currentDirection, this._workingPosition, elapsed, this.lerpTime, currentDirection);
currentDirection.addInPlace(this.followedCamera.globalPosition);
this.attachedNode.position.copyFrom(currentDirection);
// rotation
const currentRotation = new Quaternion();
currentRotation.copyFrom(this.attachedNode.rotationQuaternion);
Quaternion.SmoothToRef(currentRotation, this._workingQuaternion, elapsed, this.lerpTime, this.attachedNode.rotationQuaternion);
this.attachedNode.setParent(oldParent);
}
_addObservables() {
this._lastTick = Date.now();
this._onBeforeRender = this._scene.onBeforeRenderObservable.add(() => {
if (!this.followedCamera) {
return;
}
const tick = Date.now();
this._updateLeashing(this.followedCamera);
this._updateTransformToGoal(tick - this._lastTick);
this._lastTick = tick;
});
}
_removeObservables() {
if (this._onBeforeRender) {
this._scene.onBeforeRenderObservable.remove(this._onBeforeRender);
}
}
}
//# sourceMappingURL=followBehavior.js.map