gpu-curtains
Version:
gpu-curtains is a 3D WebGPU rendering engine. It can be used as a standalone 3D engine, but also includes extra classes focused on mapping 3d objects to DOM elements; It allows users to synchronize values such as position, sizing, or scale between them.
387 lines (386 loc) • 13.6 kB
JavaScript
import { throwWarning } from "../../utils/utils.mjs";
import { Vec3 } from "../../math/Vec3.mjs";
import { Vec2 } from "../../math/Vec2.mjs";
import { OrthographicCamera } from "../../core/cameras/OrthographicCamera.mjs";
import { PerspectiveCamera } from "../../core/cameras/PerspectiveCamera.mjs";
//#region src/extras/controls/OrbitControls.ts
const tempVec2a = new Vec2();
const tempVec2b = new Vec2();
const tempVec3 = new Vec3();
/**
* Helper to create orbit camera controls (sometimes called arc ball camera).
*
* @example
* ```javascript
* // assuming renderer is a valid CameraRenderer
* const { camera } = renderer
* const orbitControls = new OrbitControls({ camera })
* ```
*/
var OrbitControls = class {
/**
* {@link HTMLElement} (or {@link Window} element) to use for event listeners.
* @private
*/
#element = null;
/** @ignore */
#offset = new Vec3();
/** @ignore */
#pinchDist;
/** @ignore */
#isOrbiting = false;
/** @ignore */
#spherical = {
radius: 1,
phi: 0,
theta: 0
};
/** @ignore */
#rotateStart = new Vec2();
/** @ignore */
#isPaning = false;
/** @ignore */
#panStart = new Vec2();
/** @ignore */
#panDelta = new Vec3();
/** @ignore */
#_onContextMenu;
/** @ignore */
#_onMouseDown;
/** @ignore */
#_onMouseMove;
/** @ignore */
#_onMouseUp;
/** @ignore */
#_onTouchStart;
/** @ignore */
#_onTouchMove;
/** @ignore */
#_onTouchEnd;
/** @ignore */
#_onMouseWheel;
/**
* OrbitControls constructor
* @param parameters - parameters to use.
*/
constructor({ camera, element = null, target = new Vec3(), enableZoom = true, minZoom = 0, maxZoom = Infinity, zoomSpeed = 1, enableRotate = true, minPolarAngle = 0, maxPolarAngle = Math.PI, minAzimuthAngle = -Infinity, maxAzimuthAngle = Infinity, rotateSpeed = 1, enablePan = true, panSpeed = 1 }) {
if (!camera) {
throwWarning("OrbitControls: cannot initialize without a camera.");
return;
}
this.enabled = true;
this.#setBaseParams({
target,
enableZoom,
minZoom,
maxZoom,
zoomSpeed,
enableRotate,
minPolarAngle,
maxPolarAngle,
minAzimuthAngle,
maxAzimuthAngle,
rotateSpeed,
enablePan,
panSpeed
});
this.#_onContextMenu = this.#onContextMenu.bind(this);
this.#_onMouseDown = this.#onMouseDown.bind(this);
this.#_onMouseMove = this.#onMouseMove.bind(this);
this.#_onMouseUp = this.#onMouseUp.bind(this);
this.#_onTouchStart = this.#onTouchStart.bind(this);
this.#_onTouchMove = this.#onTouchMove.bind(this);
this.#_onTouchEnd = this.#onTouchEnd.bind(this);
this.#_onMouseWheel = this.#onMouseWheel.bind(this);
this.element = element ?? (typeof window !== "undefined" ? window : null);
this.useCamera(camera);
}
/**
* Allow to set or reset this {@link OrbitControls.camera | OrbitControls camera}.
* @param camera - New {@link OrbitControls.camera | camera} to use.
*/
useCamera(camera) {
this.camera = camera;
this.camera.lookAt(this.target);
this.target.onChange(() => {
this.#update();
});
this.#offset.copy(this.camera.position).sub(this.target);
this.#spherical.radius = this.#offset.length();
this.#spherical.theta = Math.atan2(this.#offset.x, this.#offset.z);
this.#spherical.phi = Math.acos(Math.min(Math.max(this.#offset.y / this.#spherical.radius, -1), 1));
this.#update();
}
/**
* Set / reset base params
* @ignore
*/
#setBaseParams({ target, enableZoom = this.enableZoom, minZoom = this.minZoom, maxZoom = this.maxZoom, zoomSpeed = this.zoomSpeed, enableRotate = this.enableRotate, minPolarAngle = this.minPolarAngle, maxPolarAngle = this.maxPolarAngle, minAzimuthAngle = this.minAzimuthAngle, maxAzimuthAngle = this.maxAzimuthAngle, rotateSpeed = this.rotateSpeed, enablePan = this.enablePan, panSpeed = this.panSpeed } = {}) {
if (target) this.target = target;
this.enableZoom = enableZoom;
this.minZoom = minZoom;
this.maxZoom = maxZoom;
this.zoomSpeed = zoomSpeed;
this.enableRotate = enableRotate;
this.minPolarAngle = minPolarAngle;
this.maxPolarAngle = maxPolarAngle;
this.minAzimuthAngle = minAzimuthAngle;
this.maxAzimuthAngle = maxAzimuthAngle;
this.rotateSpeed = rotateSpeed;
this.enablePan = enablePan;
this.panSpeed = panSpeed;
}
/**
* Reset the {@link OrbitControls} values.
* @param parameters - Parameters used to reset the values. Those are the same as {@link OrbitControlsBaseParams} with an additional position parameter to allow to override the {@link OrbitControls} position.
*/
reset({ position, target, enableZoom = this.enableZoom, minZoom = this.minZoom, maxZoom = this.maxZoom, zoomSpeed = this.zoomSpeed, enableRotate = this.enableRotate, minPolarAngle = this.minPolarAngle, maxPolarAngle = this.maxPolarAngle, minAzimuthAngle = this.minAzimuthAngle, maxAzimuthAngle = this.maxAzimuthAngle, rotateSpeed = this.rotateSpeed, enablePan = this.enablePan, panSpeed = this.panSpeed } = {}) {
this.#setBaseParams({
target,
enableZoom,
minZoom,
maxZoom,
zoomSpeed,
enableRotate,
minPolarAngle,
maxPolarAngle,
minAzimuthAngle,
maxAzimuthAngle,
rotateSpeed,
enablePan,
panSpeed
});
if (position) this.updatePosition(position);
}
/**
* Allow to override the {@link camera} position.
* @param position - new {@link camera} position to set.
*/
updatePosition(position = new Vec3()) {
position.sub(this.target);
this.#spherical.radius = position.length();
this.#spherical.theta = Math.atan2(position.x, position.z);
this.#spherical.phi = Math.acos(Math.min(Math.max(position.y / this.#spherical.radius, -1), 1));
this.#update();
}
/**
* Set the element to use for event listeners. Can remove previous event listeners first if needed.
* @param value - {@link HTMLElement} (or {@link Window} element) to use.
*/
set element(value) {
if (this.#element && (!value || this.#element !== value)) this.#removeEvents();
this.#element = value;
if (value) this.#addEvents();
}
/**
* Get our element to use for event listeners.
* @returns - {@link HTMLElement} (or {@link Window} element) used.
*/
get element() {
return this.#element;
}
/**
* Add the event listeners.
* @private
*/
#addEvents() {
this.#element.addEventListener("contextmenu", this.#_onContextMenu, false);
this.#element.addEventListener("mousedown", this.#_onMouseDown, false);
this.#element.addEventListener("mousemove", this.#_onMouseMove, false);
this.#element.addEventListener("mouseup", this.#_onMouseUp, false);
this.#element.addEventListener("touchstart", this.#_onTouchStart, { passive: false });
this.#element.addEventListener("touchmove", this.#_onTouchMove, { passive: false });
this.#element.addEventListener("touchend", this.#_onTouchEnd, false);
this.#element.addEventListener("wheel", this.#_onMouseWheel, { passive: false });
}
/**
* Remove the event listeners.
* @private
*/
#removeEvents() {
this.#element.removeEventListener("contextmenu", this.#_onContextMenu, false);
this.#element.removeEventListener("mousedown", this.#_onMouseDown, false);
this.#element.removeEventListener("mousemove", this.#_onMouseMove, false);
this.#element.removeEventListener("mouseup", this.#_onMouseUp, false);
this.#element.removeEventListener("touchstart", this.#_onTouchStart, { passive: false });
this.#element.removeEventListener("touchmove", this.#_onTouchMove, { passive: false });
this.#element.removeEventListener("touchend", this.#_onTouchEnd, false);
this.#element.removeEventListener("wheel", this.#_onMouseWheel, { passive: false });
}
/**
* Callback executed on mouse down event.
* @param e - {@link MouseEvent}.
* @private
*/
#onMouseDown(e) {
if (!this.enabled) return;
if (e.button === 0 && this.enableRotate) {
this.#isOrbiting = true;
this.#rotateStart.set(e.clientX, e.clientY);
} else if (e.button === 2 && this.enablePan) {
this.#isPaning = true;
this.#panStart.set(e.clientX, e.clientY);
}
e.stopPropagation();
e.preventDefault();
}
/**
* Callback executed on touch start event.
* @param e - {@link TouchEvent}.
* @private
*/
#onTouchStart(e) {
if (!this.enabled) return;
this.#pinchDist = 0;
if (e.touches.length === 1 && this.enableRotate) {
this.#isOrbiting = true;
this.#rotateStart.set(e.touches[0].clientX, e.touches[0].clientY);
}
}
/**
* Callback executed on mouse move event.
* @param e - {@link MouseEvent}.
*/
#onMouseMove(e) {
if (!this.enabled) return;
if (this.#isOrbiting && this.enableRotate) this.#rotate(e.clientX, e.clientY);
else if (this.#isPaning && this.enablePan) this.#pan(e.clientX, e.clientY);
}
/**
* Callback executed on touch move event.
* @param e - {@link TouchEvent}.
* @private
*/
#onTouchMove(e) {
if (!this.enabled) return;
if (e.touches.length === 2 && this.enableZoom) {
const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
if (this.#pinchDist) {
const zoom = this.#pinchDist - dist;
if (zoom) this.#zoom(zoom * 2);
}
this.#pinchDist = dist;
} else if (this.#isOrbiting && this.enableRotate) this.#rotate(e.touches[0].pageX, e.touches[0].pageY);
}
/**
* Callback executed on mouse up event.
* @param e - {@link MouseEvent}.
* @private
*/
#onMouseUp(e) {
this.#isOrbiting = false;
this.#isPaning = false;
}
/**
* Callback executed on touch end event.
* @param e - {@link MouseEvent}.
* @private
*/
#onTouchEnd(e) {
this.#isOrbiting = false;
this.#isPaning = false;
}
/**
* Callback executed on wheel event.
* @param e - {@link WheelEvent}.
* @private
*/
#onMouseWheel(e) {
if (this.enabled && this.enableZoom) {
this.#zoom(e.deltaY);
e.preventDefault();
}
}
/**
* Prevent context menu apparition on right click
* @param e - {@link MouseEvent}.
* @private
*/
#onContextMenu(e) {
if (!this.enabled) return;
e.preventDefault();
}
/**
* Update the {@link camera} position based on the {@link target} and internal values.
* @private
*/
#update() {
const sinPhiRadius = this.#spherical.radius * Math.sin(Math.max(1e-6, this.#spherical.phi));
this.#offset.x = sinPhiRadius * Math.sin(this.#spherical.theta);
this.#offset.y = this.#spherical.radius * Math.cos(this.#spherical.phi);
this.#offset.z = sinPhiRadius * Math.cos(this.#spherical.theta);
this.camera.position.copy(this.target).add(this.#offset);
this.camera.lookAt(this.target);
}
/**
* Update the {@link camera} position based on input coordinates so it rotates around the {@link target}.
* @param x - input coordinate along the X axis.
* @param y - input coordinate along the Y axis.
* @private
*/
#rotate(x, y) {
tempVec2a.set(x, y);
tempVec2b.copy(tempVec2a).sub(this.#rotateStart).multiplyScalar(this.rotateSpeed);
if (this.camera instanceof PerspectiveCamera) {
this.#spherical.theta -= 2 * Math.PI * tempVec2b.x / this.camera.size.height;
this.#spherical.phi -= 2 * Math.PI * tempVec2b.y / this.camera.size.height;
} else if (this.camera instanceof OrthographicCamera) {
const height = (this.camera.top - this.camera.bottom) * 2;
tempVec2b.multiplyScalar(1 / height);
this.#spherical.theta -= 2 * Math.PI * tempVec2b.x / height;
this.#spherical.phi -= 2 * Math.PI * tempVec2b.y / height;
}
this.#spherical.theta = Math.min(this.maxAzimuthAngle, Math.max(this.minAzimuthAngle, this.#spherical.theta));
this.#spherical.phi = Math.min(this.maxPolarAngle, Math.max(this.minPolarAngle, this.#spherical.phi));
this.#rotateStart.copy(tempVec2a);
this.#update();
}
/**
* Pan the {@link camera} position based on input coordinates by updating {@link target}.
* @param x - input coordinate along the X axis.
* @param y - input coordinate along the Y axis.
* @private
*/
#pan(x, y) {
tempVec2a.set(x, y);
tempVec2b.copy(tempVec2a).sub(this.#panStart).multiplyScalar(this.panSpeed);
this.#panDelta.set(0);
tempVec3.copy(this.camera.position).sub(this.target);
let targetDistance = tempVec3.length();
tempVec3.set(this.camera.modelMatrix.elements[0], this.camera.modelMatrix.elements[1], this.camera.modelMatrix.elements[2]);
if (this.camera instanceof PerspectiveCamera) {
targetDistance *= Math.tan(this.camera.fov / 2 * Math.PI / 180);
tempVec3.multiplyScalar(-(2 * tempVec2b.x * targetDistance) / this.camera.size.height);
} else if (this.camera instanceof OrthographicCamera) {
targetDistance *= 1 / ((this.camera.top - this.camera.bottom) * 2);
tempVec3.multiplyScalar(-(2 * tempVec2b.x * targetDistance) / ((this.camera.right - this.camera.left) * 2));
}
this.#panDelta.add(tempVec3);
tempVec3.set(this.camera.modelMatrix.elements[4], this.camera.modelMatrix.elements[5], this.camera.modelMatrix.elements[6]);
if (this.camera instanceof PerspectiveCamera) tempVec3.multiplyScalar(2 * tempVec2b.y * targetDistance / this.camera.size.height);
else if (this.camera instanceof OrthographicCamera) tempVec3.multiplyScalar(2 * tempVec2b.y * targetDistance / ((this.camera.top - this.camera.bottom) * 2));
this.#panDelta.add(tempVec3);
this.#panStart.copy(tempVec2a);
this.target.add(this.#panDelta);
this.#offset.copy(this.camera.position).sub(this.target);
this.#spherical.radius = this.#offset.length();
this.#update();
}
/**
* Move the {@link camera} forward or backward.
* @param value - new value to use for zoom.
* @private
*/
#zoom(value) {
this.#spherical.radius = Math.min(this.maxZoom, Math.max(this.minZoom + 1e-6, this.#spherical.radius + value * this.zoomSpeed / 100));
this.#update();
}
/**
* Destroy the {@link OrbitControls}.
*/
destroy() {
this.element = null;
}
};
//#endregion
export { OrbitControls };