UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

397 lines 19.3 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var ViewBox_1; import { Matrix4, PerspectiveCamera } from "three"; import { isDevEnvironment } from "../../engine/debug/debug.js"; import { Gizmos } from "../../engine/engine_gizmos.js"; import { serializable } from "../../engine/engine_serialization_decorator.js"; import { getTempVector } from "../../engine/engine_three_utils.js"; import { registerType } from "../../engine/engine_typestore.js"; import { getParam } from "../../engine/engine_utils.js"; import { RGBAColor } from "../../engine/js-extensions/RGBAColor.js"; import { Behaviour } from "../Component.js"; const debugParam = getParam("debugviewbox"); const disabledGizmoColor = new RGBAColor(.5, .5, .5, .5); /** * [ViewBox](https://engine.needle.tools/docs/api/ViewBox) automatically fits a defined box area into the camera view regardless of screen size or aspect ratio. * This component is useful for framing characters, objects, or scenes in the center of the view while ensuring they remain fully visible. * You can animate or scale the viewbox to create dynamic zoom effects, cinematic transitions, or responsive framing. * * [![](https://cloud.needle.tools/-/media/Thy6svVftsIC6Z_wIxUJMA.gif)](https://engine.needle.tools/samples/bike-scrollytelling-responsive-3d) * * The ViewBox component works by adjusting the camera's focus rect settings (offset and zoom) to ensure that the box defined by the * GameObject's position, rotation, and scale fits perfectly within the visible viewport. It supports different modes for one-time * fitting or continuous adjustment, making it versatile for both static compositions and animated sequences. * * **Key Features:** * - Automatically adjusts camera framing to fit the box area * - Works with any screen size and aspect ratio * - Supports one-time fitting or continuous updates * - Can be animated for dynamic zoom and framing effects * - Multiple ViewBoxes can be active, with the most recently enabled taking priority * - Handles camera positioning to ensure the box is visible (moves camera if inside the box) * * **Common Use Cases:** * - Character framing in cutscenes or dialogue * - Product showcases with guaranteed visibility * - Scrollytelling experiences with animated camera movements * - Responsive layouts that adapt to different screen sizes * - UI-driven camera transitions * * - [Example on needle.run](https://viewbox-demo-z23hmxbz2gkayo-z1nyzm6.needle.run/) * - [Scrollytelling Demo using animated Viewbox](https://scrollytelling-bike-z23hmxb2gnu5a.needle.run/) * - [Example on Stackblitz](https://stackblitz.com/edit/needle-engine-view-box-example) * * @example Basic setup - Add a ViewBox component to frame an object * ```ts * const viewBox = new Object3D(); * viewBox.position.set(0, 1, 0); // Position the viewbox center * viewBox.scale.set(2, 2, 2); // Define the box size * viewBox.addComponent(ViewBox, { debug: true }); * scene.add(viewBox); * ``` * * @example Animated ViewBox for zoom effects * ```ts * const viewBox = new Object3D(); * viewBox.addComponent(ViewBox, { mode: "continuous" }); * scene.add(viewBox); * * // Animate the viewbox scale over time * function update() { * const scale = 1 + Math.sin(Date.now() * 0.001) * 0.5; * viewBox.scale.setScalar(scale); * } * ``` * * @example One-time fitting with user control afterwards * ```ts * const viewBox = new Object3D(); * viewBox.addComponent(ViewBox, { * mode: "once", // Fit once, then allow free camera control * referenceFieldOfView: 60 * }); * scene.add(viewBox); * ``` * * @see {@link CameraComponent} - The camera component that ViewBox controls * @see {@link OrbitControls} - Camera controls that work alongside ViewBox * @see {@link DragControls} - Alternative camera controls compatible with ViewBox * @see {@link LookAtConstraint} - Another way to control camera targeting * @see {@link SceneSwitcher} - Can be combined with ViewBox for scene transitions * @see {@link Context.setCameraFocusRect} - The underlying focus rect API used by ViewBox * @see {@link Context.focusRectSettings} - Manual control of focus rect settings * @see {@link ViewBoxMode} - The mode type for controlling ViewBox behavior * * @summary Automatically fits a box area into the camera view * @category Camera and Controls * @group Components * @component */ let ViewBox = class ViewBox extends Behaviour { static { ViewBox_1 = this; } /** * Array of all active ViewBox instances in the scene. * When multiple ViewBoxes are enabled, the last one in the array (most recently enabled) takes priority and controls the camera. * Other ViewBoxes remain registered but inactive, displayed with a dimmed gizmo color when debug visualization is enabled. */ static instances = []; /** * The reference field of view (in degrees) used to calculate how the box should fit within the camera view. * This determines the baseline camera FOV for fitting calculations. * * **Behavior:** * - If set to `-1` (default), the component will automatically use the camera's FOV on the first frame * - Should typically match your camera's FOV for predictable framing * - Can be set to a different value to create specific framing effects * * **Example:** * If your camera has an FOV of 60° and you set `referenceFieldOfView` to 60, the ViewBox will fit objects * as they would appear with that field of view. Setting it to a wider FOV (e.g., 90) makes objects appear * smaller, while a narrower FOV (e.g., 30) makes them appear larger. * * @see {@link CameraComponent} for the camera component and its FOV property * @default -1 (automatically uses the camera's FOV on the first frame) */ referenceFieldOfView = -1; /** * Controls how the ViewBox applies camera adjustments. * * **Modes:** * - `"once"`: Applies the framing adjustment once when the ViewBox becomes active, then stops updating. * This is ideal when you want to frame the view initially but allow users to freely zoom, pan, or orbit afterwards. * Perfect for interactive scenes where you want a good starting view but full user control. * * - `"continuous"`: Continuously updates the camera framing while this ViewBox is active. * Use this when animating or scaling the ViewBox over time, or when you need the framing to constantly adjust. * Great for cutscenes, scrollytelling, or any scenario with animated ViewBoxes. * * **Example Use Cases:** * - Set to `"once"` for: Initial scene framing, product showcases where users explore freely after initial framing * - Set to `"continuous"` for: Animated zoom effects, scrollytelling sequences, dynamic camera movements tied to ViewBox transforms * * @see {@link ViewBoxMode} for the type definition * @default "continuous" */ get mode() { return this._mode; } set mode(v) { if (v === this._mode) return; this._mode = v; if (v === "once") this._applyCount = 0; if (debugParam || this.debug) console.debug("[ViewBox] Set mode:", v); } _mode = "continuous"; /** * Enables debug visualization and logging for this ViewBox instance. * * **When enabled, you will see:** * - A yellow wireframe box showing the active ViewBox bounds in 3D space * - Gray wireframe boxes for inactive ViewBox instances * - A red dashed outline on screen showing the projected box in 2D (when using `?debugviewbox` URL parameter) * - Console logs for mode changes, FOV settings, and camera adjustments * * **Tip:** You can also enable debug visualization globally for all ViewBoxes by adding `?debugviewbox` to your URL. * * @see {@link Gizmos} for the gizmo rendering system used for debug visualization * @default false */ debug = false; /** @internal */ onEnable() { if (debugParam || this.debug || isDevEnvironment()) console.debug("[ViewBox] Using camera fov:", this.referenceFieldOfView); // register instance ViewBox_1.instances.push(this); this._applyCount = 0; this.removeUpdateCallback(); this.context.pre_render_callbacks.push(this.internalUpdate); } /** @internal */ onDisable() { if (debugParam || this.debug) console.debug("[ViewBox] Disabled"); // unregister instance const idx = ViewBox_1.instances.indexOf(this); if (idx !== -1) ViewBox_1.instances.splice(idx, 1); this._projectedBoxElement?.remove(); this.removeUpdateCallback(); } removeUpdateCallback() { // remove prerender callback const cbIdx = this.context.pre_render_callbacks.indexOf(this.internalUpdate); if (cbIdx !== -1) this.context.pre_render_callbacks.splice(cbIdx, 1); } static _tempProjectionMatrix = new Matrix4(); static _tempProjectionMatrixInverse = new Matrix4(); _applyCount = 0; internalUpdate = () => { if (this.context.isInXR) return; if (this.destroyed || !this.activeAndEnabled) return; const isActive = ViewBox_1.instances[ViewBox_1.instances.length - 1] === this; if (!isActive) { if (debugParam || this.debug) { Gizmos.DrawWireBox(this.gameObject.worldPosition, this.gameObject.worldScale, disabledGizmoColor); } return; } if (debugParam || this.debug) Gizmos.DrawWireBox(this.gameObject.worldPosition, this.gameObject.worldScale, 0xdddd00, 0, true, this.gameObject.worldQuaternion); // calculate box size to fit the camera frustrum size at the current position (just scale) const camera = this.context.mainCamera; if (!camera) return; if (!(camera instanceof PerspectiveCamera)) { // TODO: support orthographic camera return; } if (this.referenceFieldOfView === undefined || this.referenceFieldOfView === -1) { this.referenceFieldOfView = camera.fov; console.debug("[ViewBox] No referenceFieldOfView set, using camera fov:", this.referenceFieldOfView); } if (this.referenceFieldOfView === undefined || this.referenceFieldOfView <= 0) { if (debugParam || this.debug) console.warn("[ViewBox] No valid referenceFieldOfView set, cannot adjust box size:", this.referenceFieldOfView); return; } if (this._applyCount >= 1 && this.mode === "once") { return; } this._applyCount++; const domWidth = this.context.domWidth; const domHeight = this.context.domHeight; let rectWidth = domWidth; let rectHeight = domHeight; let diffWidth = 1; let diffHeight = 1; // use focus rect if available const focusRectSize = this.context.focusRectSize; if (focusRectSize) { rectWidth = focusRectSize.width; rectHeight = focusRectSize.height; diffWidth = domWidth / rectWidth; diffHeight = domHeight / rectHeight; } // Copy the projection matrix and restore values so we can reset it later ViewBox_1._tempProjectionMatrix.copy(camera.projectionMatrix); ViewBox_1._tempProjectionMatrixInverse.copy(camera.projectionMatrixInverse); const view = camera.view; const cameraZoom = camera.zoom; const aspect = camera.aspect; const fov = camera.fov; // Set values to default so we can calculate the box size correctly camera.view = null; camera.zoom = 1; camera.fov = this.referenceFieldOfView; camera.updateProjectionMatrix(); const boxPosition = this.gameObject.worldPosition; const boxScale = this.gameObject.worldScale; const cameraPosition = camera.worldPosition; const distance = cameraPosition.distanceTo(boxPosition); // #region camera fixes // If the camera is inside the box, move it out const boxSizeMax = Math.max(boxScale.x, boxScale.y, boxScale.z); const direction = getTempVector(cameraPosition).sub(boxPosition); if (distance < boxSizeMax) { // move camera out of bounds if (this.debug || debugParam) console.warn("[ViewBox] Moving camera out of bounds", distance, "<", boxSizeMax); const positionDirection = getTempVector(direction); positionDirection.y *= .00000001; // stay on horizontal plane mostly positionDirection.normalize(); const lengthToMove = (boxSizeMax - distance); const newPosition = cameraPosition.add(positionDirection.multiplyScalar(lengthToMove)); camera.worldPosition = newPosition.lerp(cameraPosition, 1 - this.context.time.deltaTime); } // Ensure the camera looks at the ViewBox // TOOD: smooth lookat over multiple frames if we have multiple viewboxes // const dot = direction.normalize().dot(camera.worldForward); // if (dot < .9) { // console.log(dot); // const targetRotation = direction; // const rotation = getTempQuaternion(); // rotation.setFromUnitVectors(camera.worldForward.multiplyScalar(-1), targetRotation); // camera.worldQuaternion = rotation; // camera.updateMatrixWorld(); // } const boxPositionInCameraSpace = getTempVector(boxPosition); camera.worldToLocal(boxPositionInCameraSpace); camera.lookAt(boxPosition); camera.updateMatrixWorld(); // #region calculate fit const vFOV = this.referenceFieldOfView * Math.PI / 180; // convert vertical fov to radians const height = 2 * Math.tan(vFOV / 2) * distance; // visible height const width = height * camera.aspect; // visible width const projectedBox = this.projectBoxIntoCamera(camera, 1); // return const boxWidth = (projectedBox.maxX - projectedBox.minX); const boxHeight = (projectedBox.maxY - projectedBox.minY); const scale = this.fit(boxWidth * camera.aspect, boxHeight, width / diffWidth, height / diffHeight); const zoom = scale / (height * .5); // console.log({ scale, width, height, boxWidth: boxWidth * camera.aspect, boxHeight, diffWidth, diffHeight, aspect: camera.aspect, distance }) // this.context.focusRectSettings.zoom = 1.39; // if (!this.context.focusRect) this.context.setCameraFocusRect(this.context.domElement); // return const vec = getTempVector(boxPosition); vec.project(camera); this.context.focusRectSettings.offsetX = vec.x; this.context.focusRectSettings.offsetY = vec.y; this.context.focusRectSettings.zoom = zoom; // if we don't have a focus rect yet, set it to the dom element if (!this.context.focusRect) this.context.setCameraFocusRect(this.context.domElement); // Reset values camera.view = view; camera.zoom = cameraZoom; camera.aspect = aspect; camera.fov = fov; camera.projectionMatrix.copy(ViewBox_1._tempProjectionMatrix); camera.projectionMatrixInverse.copy(ViewBox_1._tempProjectionMatrixInverse); // BACKLOG: some code for box scale of an object (different component) // this.gameObject.worldScale = getTempVector(width, height, worldscale.z); // this.gameObject.scale.multiplyScalar(.98) // const minscale = Math.min(width, height); // console.log(width, height); // this.gameObject.worldScale = getTempVector(scale, scale, scale); }; /** * Cover fit */ fit(width1, height1, width2, height2) { const scaleX = width2 / width1; const scaleY = height2 / height1; return Math.min(scaleX, scaleY); } projectBoxIntoCamera(camera, _factor) { const factor = .5 * _factor; const corners = [ getTempVector(-factor, -factor, -factor), getTempVector(factor, -factor, -factor), getTempVector(-factor, factor, -factor), getTempVector(factor, factor, -factor), getTempVector(-factor, -factor, factor), getTempVector(factor, -factor, factor), getTempVector(-factor, factor, factor), getTempVector(factor, factor, factor), ]; let minX = Number.POSITIVE_INFINITY; let maxX = Number.NEGATIVE_INFINITY; let minY = Number.POSITIVE_INFINITY; let maxY = Number.NEGATIVE_INFINITY; for (let i = 0; i < corners.length; i++) { const c = corners[i]; c.applyMatrix4(this.gameObject.matrixWorld); c.project(camera); if (c.x < minX) minX = c.x; if (c.x > maxX) maxX = c.x; if (c.y < minY) minY = c.y; if (c.y > maxY) maxY = c.y; } if (debugParam) { if (!this._projectedBoxElement) { this._projectedBoxElement = document.createElement("div"); } if (this._projectedBoxElement.parentElement !== this.context.domElement) this.context.domElement.appendChild(this._projectedBoxElement); this._projectedBoxElement.style.position = "fixed"; // dotted but with larger gaps this._projectedBoxElement.style.outline = "2px dashed rgba(255,0,0,.5)"; this._projectedBoxElement.style.left = ((minX * .5 + .5) * this.context.domWidth) + "px"; this._projectedBoxElement.style.top = ((-maxY * .5 + .5) * this.context.domHeight) + "px"; this._projectedBoxElement.style.width = ((maxX - minX) * .5 * this.context.domWidth) + "px"; this._projectedBoxElement.style.height = ((maxY - minY) * .5 * this.context.domHeight) + "px"; this._projectedBoxElement.style.pointerEvents = "none"; this._projectedBoxElement.style.zIndex = "1000"; } return { minX, maxX, minY, maxY }; } _projectedBoxElement = null; }; __decorate([ serializable() ], ViewBox.prototype, "referenceFieldOfView", void 0); __decorate([ serializable() ], ViewBox.prototype, "mode", null); __decorate([ serializable() ], ViewBox.prototype, "debug", void 0); ViewBox = ViewBox_1 = __decorate([ registerType ], ViewBox); export { ViewBox }; //# sourceMappingURL=ViewBox.js.map