// // Created by Luis Cuenca on 11/14/19 // Copyright 2019 High Fidelity, Inc. // // // Distributed under the Apache License, Version 2.0. // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // (function() { var ZoomStatus = { "zoomingIn" : 0, // The camera is moving towards the selected entity "zoomingOut" : 1, // The camera is moving away from the selected entity "zoomedIn" : 2, // The camera is locked looking at the selected entity "zoomedOut" : 4, // The camera is on its initial position and mode "consumed" : 5 // The zooming loop has been completed } var FocusType = { "avatar" : 0, "entity" : 1 } var EasingFunctions = { easeInOutQuad: function (t) { return t<.5 ? 2*t*t : -1+(4-2*t)*t }, // accelerating from zero velocity easeInCubic: function (t) { return t*t*t }, // decelerating to zero velocity easeOutCubic: function (t) { return (--t)*t*t+1 }, // acceleration until halfway, then deceleration easeInOutCubic: function (t) { return t<.5 ? 4*t*t*t : (t-1)*(2*t-2)*(2*t-2)+1 }, // accelerating from zero velocity easeInQuart: function (t) { return t*t*t*t }, // decelerating to zero velocity easeOutQuart: function (t) { return 1-(--t)*t*t*t }, // acceleration until halfway, then deceleration easeInOutQuart: function (t) { return t<.5 ? 8*t*t*t*t : 1-8*(--t)*t*t*t }, // accelerating from zero velocity easeInQuint: function (t) { return t*t*t*t*t }, // decelerating to zero velocity easeOutQuint: function (t) { return 1+(--t)*t*t*t*t }, // acceleration until halfway, then deceleration easeInOutQuint: function (t) { return t<.5 ? 16*t*t*t*t*t : 1+16*(--t)*t*t*t*t } }; // Class that manages the zoom effect var ZoomData = function(type, lookAt, focusNormal, focusDimensions, velocity, maxDuration) { var self = this; this.focusType = type; this.lookAt = lookAt; // Look at 3d point in world coordinates this.focusDimensions = focusDimensions; // 2d dimensions of the bounding box projection approximation of the selected entity this.focusNormal = focusNormal; // The normal vector provided by the ray intersection used to initialize the zoom this.velocity = velocity; // Max velocity of the camera zoom effect this.maxDuration = maxDuration; // Max duration of the camera zoom effect this.initialPos = Camera.getPosition(); this.initialRot = Camera.getOrientation(); this.interpolatedPos = this.initialPos; this.interpolatedRot = this.initialRot; this.initialMode = Camera.mode; this.initialOffset = Vec3.distance(self.initialPos, MyAvatar.getDefaultEyePosition()); // Save the offset distance from the camera to the avatar eyes (boom) this.finalPos = Vec3.ZERO; this.finalRot = Quat.IDENTITY; this.direction = Vec3.ZERO; // Direction vector for the camera path this.distance = Vec3.ZERO; // Total distance that the camera needs to cover to zoom in along the path this.totalTime = 0.0; // Total time needed to cover the zoom path this.elapsedTime = 0.0; var MAX_ZOOM_IN_AMOUNT = 0.6; // Maximum percentage of camera proximity to the selected entity from the look at point var MAX_ZOOM_OUT_AMOUNT = 0.2; // Maximum percentage of camera remoteness to the selected entity from the look at point this.maxZoomInAmount = MAX_ZOOM_IN_AMOUNT; this.maxZoomOutAmount = MAX_ZOOM_OUT_AMOUNT; this.currentZoomAmount = 0.0; this.zoomPanOffset = {x: 0.5 * Window.innerWidth, y: 0.5 * Window.innerHeight}; this.zoomPanDelta = {x: 0.0, y: 0.0}; this.status = ZoomStatus.zoomedOut; var OWN_CAMERA_CHANGE_MAX_FRAMES = 30; this.ownCameraChangeElapseTime = 0.0; // Variables used to track if the camera mode change is triggered by this script this.ownCameraChange = false; // or by user input, since the later needs to abort the zoom loop // Function called when the user release the mouse while panning the camera when using the secondary zoom this.applyZoomPan = function() { self.zoomPanOffset.x += self.zoomPanDelta.x; self.zoomPanOffset.y += self.zoomPanDelta.y; } // Function called when the user moves the mouse while panning the camera when using the secondary zoom this.setZoomPanDelta = function(x, y) { self.zoomPanDelta.x = x; self.zoomPanDelta.y = y; var totalX = self.zoomPanOffset.x + self.zoomPanDelta.x; var totalY = self.zoomPanOffset.y + self.zoomPanDelta.y; totalX = Math.min(Math.max(totalX, 0.0), Window.innerWidth); totalY = Math.min(Math.max(totalY, 0.0), Window.innerHeight); self.zoomPanDelta.x = totalX - self.zoomPanOffset.x; self.zoomPanDelta.y = totalY - self.zoomPanOffset.y; self.updateSuperPan(totalX, totalY); } // This function computes the correct distance to the selected entity so it ends up fitted on the screen this.getFocusDistance = function(zoomDims) { var objAspect = zoomDims.x / zoomDims.y; var camAspect = Camera.frustum.aspectRatio; var m = 0.0; if (objAspect < camAspect) { m = zoomDims.y; } else { m = zoomDims.x / camAspect; } var DEGREE_TO_RADIAN = 0.0174533; var fov = DEGREE_TO_RADIAN * Camera.frustum.fieldOfView; return (0.5 * m) / Math.tan(0.5 * fov); } // This function computes the final camera position and rotation this.computeFinalForm = function() { self.finalPos = Vec3.sum(self.lookAt, Vec3.multiply(self.getFocusDistance(self.focusDimensions), self.focusNormal)); self.finalRot = Quat.lookAtSimple(self.finalPos, self.lookAt); } // Function that computes the time and path information for the camera zoom in this.computeRouteIn = function() { var railVector = Vec3.subtract(self.finalPos, self.initialPos); self.direction = Vec3.normalize(railVector); self.distance = Vec3.length(railVector); self.totalTime = self.distance / self.velocity; self.totalTime = self.totalTime > self.maxDuration ? self.maxDuration : self.totalTime; } // Function that computes the time and path information for the camera zoom out // Since the camera orientation and position gets reset when changing camera modes, // the camera path while zooming out is different from the path zooming in. this.computeRouteOut = function() { self.finalPos = Camera.getPosition(); var camOffset = Vec3.ZERO; var myAvatarRotation = MyAvatar.orientation; if (self.initialMode.indexOf("first") != -1) { self.initialRot = myAvatarRotation; } else { var lookAtPoint = MyAvatar.getDefaultEyePosition(); var lookFromSign = self.initialMode.indexOf("selfie") != -1 ? 1 : -1; var lookFromPoint = Vec3.sum(lookAtPoint, Vec3.multiply(self.initialOffset * lookFromSign, Quat.getFront(myAvatarRotation))); self.initialRot = Quat.lookAtSimple(lookFromPoint, lookAtPoint); self.initialPos = lookFromPoint; } self.computeRouteIn(); } // This function initiate the camera zoom in this.initZoomIn = function() { if (self.status === ZoomStatus.zoomedOut) { self.computeRouteIn(); self.status = ZoomStatus.zoomingIn; self.changeCameraMode("independent"); self.elapsedTime = 0.0; } } // This function initiate the camera zoom in this.initZoomOut = function() { if (self.status === ZoomStatus.zoomedIn) { self.computeRouteOut(); self.status = ZoomStatus.zoomingOut; self.changeCameraMode("independent"); self.elapsedTime = 0.0; } } // This function checks if the zoom loop needs update this.needsUpdate = function() { return self.status === ZoomStatus.zoomingIn || self.status === ZoomStatus.zoomingOut; } // This function connects to the update signal only when zoom in or zoom out is needed this.updateZoom = function(deltaTime) { if (self.ownCameraChange) { self.ownCameraChange = self.ownCameraChangeElapseTime < OWN_CAMERA_CHANGE_MAX_FRAMES * deltaTime; self.ownCameraChangeElapseTime += deltaTime; } if (!self.needsUpdate) { return; } if (self.elapsedTime < self.totalTime) { var ratio = EasingFunctions.easeInOutQuart(self.elapsedTime / self.totalTime); if (self.status === ZoomStatus.zoomingIn) { var curDist = self.distance * ratio; var addition = Vec3.multiply(curDist, self.direction); self.interpolatedPos = Vec3.sum(self.initialPos, addition); self.interpolatedRot = Quat.mix(self.initialRot, self.finalRot, ratio); } else if (self.status === ZoomStatus.zoomingOut) { self.interpolatedPos = Vec3.sum(self.finalPos, Vec3.multiply(self.distance * ratio, Vec3.multiply(-1, self.direction))); self.interpolatedRot = Quat.mix(self.finalRot, self.initialRot, ratio); } self.elapsedTime += deltaTime; Camera.setPosition(self.interpolatedPos); Camera.setOrientation(self.interpolatedRot); } else { Camera.setPosition(self.finalPos); Camera.setOrientation(self.finalRot); if (self.status === ZoomStatus.zoomingIn) { self.status = ZoomStatus.zoomedIn; } else if (self.status === ZoomStatus.zoomingOut) { self.status = ZoomStatus.consumed; self.changeCameraMode(self.initialMode); } } } // This function recomputes the camera distance to the selected item in order to fit it on the screen this.resetZoomAspect = function() { self.computeFinalForm(); self.computeRouteIn(); Camera.setPosition(self.finalPos); } // This function updates the secondary zoom distance to the selected entity according to the mouse wheel event this.updateSuperZoom = function(delta) { var ZOOM_STEP = 0.1; self.currentZoomAmount = self.currentZoomAmount + (delta < 0.0 ? -1 : 1) * ZOOM_STEP; self.currentZoomAmount = Math.min(Math.max(self.currentZoomAmount, -self.maxZoomOutAmount), self.maxZoomInAmount); self.updateSuperPan(self.zoomPanOffset.x, self.zoomPanOffset.y); } // This function updates the secondary zoom panning according to the mouse move event this.updateSuperPan = function(x, y) { var zoomOffset = Vec3.multiply(self.currentZoomAmount, Vec3.subtract(self.lookAt, self.finalPos)); var xRatio = 0.5 - x / Window.innerWidth; var yRatio = 0.5 - y / Window.innerHeight; var cameraOrientation = Camera.getOrientation(); var cameraY = Quat.getUp(cameraOrientation); var cameraX = Vec3.multiply(-1, Quat.getRight(cameraOrientation)); var xOffset = Vec3.multiply(xRatio * self.focusDimensions.x, cameraX); var yOffset = Vec3.multiply(yRatio * self.focusDimensions.y, cameraY); zoomOffset = Vec3.sum(zoomOffset, xOffset); zoomOffset = Vec3.sum(zoomOffset, yOffset); Camera.setPosition(Vec3.sum(self.finalPos, zoomOffset)); } // This function aborts the zoom loop when the user changes the camera mode this.abort = function() { self.changeCameraMode(self.initialMode); } // This function changes the camera mode this.changeCameraMode = function(mode) { self.ownCameraChange = true; self.ownCameraChangeElapseTime = 0.0; Camera.mode = mode; } this.computeFinalForm(); } // Class that manages the mouse events and computes the 3d structure data of the selected entity or avatar var ZoomOnAnything = function() { var self = this; this.zoomEntityData; // The selected entity's data this.zoomCameraPos; var ZOOM_MAX_VELOCITY = 15.0; // meters per second var ZOOM_MAX_DURATION = 1.0; // seconds this.zoomDelta = {x: 0.0, y: 0.0}; // mouse move delta while panning this.isPanning = false; this.screenPointRef = {x: 0.0, y: 0.0}; // Screen pixels reference used to compute the zoomDelta this.connected = false; // This function estimates the projection axis and the dimensions along that axis of the selected entity this.getEntityDimsFromNormal = function(dims, rot, normal) { var zoomXNormal = Vec3.multiplyQbyV(rot, Vec3.UNIT_X); var zoomYNormal = Vec3.multiplyQbyV(rot, Vec3.UNIT_Y); var zoomZNormal = Vec3.multiplyQbyV(rot, Vec3.UNIT_Z); var affinities = [ {axis: "x", normal: zoomXNormal, affin: Math.abs(Vec3.dot(zoomXNormal, normal)), dims: {x: dims.z, y: dims.y}}, {axis: "y", normal: zoomYNormal, affin: Math.abs(Vec3.dot(zoomYNormal, normal)), dims: {x: dims.x, y: dims.z}}, {axis: "z", normal: zoomZNormal, affin: Math.abs(Vec3.dot(zoomZNormal, normal)), dims: {x: dims.x, y: dims.y}} ]; affinities.sort(function(a, b) { return b.affin - a.affin; }); return affinities[0]; } // This function estimates the appropriate focus point when the selected entity is an avatar this.getAvatarFocusPoint = function(avatar) { var rEyeIndex = avatar.getJointIndex("RightEye"); var lEyeIndex = avatar.getJointIndex("LeftEye"); var headIndex = avatar.getJointIndex("Head"); var focusPoint = Vec3.ZERO; var validPoint = false; var count = 0; if (rEyeIndex != -1) { focusPoint = Vec3.sum(focusPoint, avatar.getJointPosition(rEyeIndex)); validPoint = true; count++; } if (lEyeIndex != -1) { var leftEyePos = avatar.getJointPosition(lEyeIndex); var NORMAL_EYE_DISTANCE = 0.1; focusPoint = Vec3.sum(focusPoint, leftEyePos); validPoint = true; count++; } if (headIndex != -1) { focusPoint = Vec3.sum(focusPoint, avatar.getJointPosition(headIndex)); validPoint = true; count++; } if (!validPoint) { focusPoint = avatar.getJointPosition("Hips"); count++; } return Vec3.multiply(1.0/count, focusPoint); } // This function sets the data needed to zoom in on an avatar this.getZoomDataFromAvatar = function(avatarID, skinToBoneDist, zoomVelocity, maxDuration) { var headDiam = 2.0 * skinToBoneDist; // Head diameter is estimated based on the distance to the bone headDiam = headDiam < 0.5 ? 0.5 : headDiam; var avatar = AvatarList.getAvatar(avatarID); var focusPoint = self.getAvatarFocusPoint(avatar); var focusDims = {x: headDiam, y: headDiam}; var focusNormal = Quat.getFront(avatar.orientation); var zoomData = new ZoomData(FocusType.avatar, focusPoint, focusNormal, focusDims, zoomVelocity, maxDuration); return zoomData; } // This function sets the data needed to zoom in on an entity this.getZoomDataFromEntity = function(intersection, objectProps, zoomVelocity, maxDuration) { var position = objectProps.position; var dimensions = objectProps.dimensions; var rotation = objectProps.rotation; var focusNormal = intersection.surfaceNormal; var dimsResult = self.getEntityDimsFromNormal(dimensions, rotation, focusNormal); var focusDims = dimsResult.dims; var focusDepth = Vec3.dot(Vec3.subtract(intersection.intersection, position), dimsResult.normal); var newPosition = Vec3.sum(position, Vec3.multiply(focusDepth, dimsResult.normal)); var zoomData = new ZoomData(FocusType.entity, newPosition, focusNormal, focusDims, zoomVelocity, maxDuration); return zoomData; } // This function initiate the zoom loop on the selected entity this.zoomOnEntity = function(intersection, objectProps) { self.zoomEntityData = self.getZoomDataFromEntity(intersection, objectProps, ZOOM_MAX_VELOCITY, ZOOM_MAX_DURATION); self.zoomEntityData.initZoomIn(); } // This function initiates the zoom loop on the selected avatar this.zoomOnAvatar = function(avatarID, skinToBoneDist) { self.zoomEntityData = self.getZoomDataFromAvatar(avatarID, skinToBoneDist, ZOOM_MAX_VELOCITY, ZOOM_MAX_DURATION); self.zoomEntityData.initZoomIn(); } // This function connects to the Script.update signal this.connect = function() { self.connected = true; Script.update.connect(self.updateZoom); } // This function disconnects to the Script.update signal this.disconnect = function() { self.connected = false; Script.update.disconnect(self.updateZoom); } // This function get connected to the Script.update signal this.updateZoom = function(deltaTime) { if (self.zoomEntityData) { if (self.zoomEntityData.needsUpdate()) { self.zoomEntityData.updateZoom(deltaTime); if (self.zoomEntityData.status === ZoomStatus.consumed) { self.disconnect(); self.zoomEntityData = undefined; } } else { self.disconnect(); } } else if (self.connected) { self.disconnect(); } } // This function listen to the mouse double click that evaluates the selected entity and the intersection data that initiates the zoom loop this.mouseDoublePressEvent = function(event) { if (event.isLeftButton) { self.connect(); if (!self.zoomEntityData) { var pickRay = Camera.computePickRay(event.x, event.y); var intersection = AvatarManager.findRayIntersection({origin: pickRay.origin, direction: pickRay.direction}, [], [MyAvatar.sessionUUID], false); if (!intersection.intersects) { intersection = Entities.findRayIntersection({origin: pickRay.origin, direction: pickRay.direction}, true); var entityProps = Entities.getEntityProperties(intersection.entityID); if (entityProps.type === "Shape") { var FIND_SHAPES_DISTANCE = 10.0; var shapes = Entities.findEntitiesByType("Shape", intersection.intersection, FIND_SHAPES_DISTANCE); intersection = Entities.findRayIntersection({origin: pickRay.origin, direction: pickRay.direction}, true, [], shapes); entityProps = Entities.getEntityProperties(intersection.entityID); } if (!entityProps.dimensions) { return; } self.zoomOnEntity(intersection, entityProps); } else { var avatar = AvatarList.getAvatar(intersection.avatarID); var skinToBoneDist = Vec3.distance(intersection.intersection, avatar.getJointPosition(intersection.jointIndex)); self.zoomOnAvatar(intersection.avatarID, skinToBoneDist); } } else if (!self.zoomEntityData.needsUpdate()){ self.zoomEntityData.initZoomOut(); } } } // This function listen to the mouse press event to initiate panning while on secondary zoom this.mousePressEvent = function(event) { if (event.isRightButton) { self.isPanning = true; self.screenPointRef = {x: event.x, y: event.y}; } } // This function listen to the mouse release event to apply the pan delta while on secondary zoom this.mouseReleaseEvent = function(event) { if (event.isRightButton) { if (self.zoomEntityData) { self.zoomEntityData.applyZoomPan(); self.isPanning = false; self.screenPointRef = {x: 0, y: 0}; } } } // This function listen to the mouse move event to modify the pan delta coordenates while on secondary zoom this.mouseMoveEvent = function(event) { if (event.isRightButton) { if (self.isPanning && self.zoomEntityData) { self.zoomEntityData.setZoomPanDelta(event.x - self.screenPointRef.x, event.y - self.screenPointRef.y); } } } // This function listen to the mouse wheel event to modify the ammount of secondary zoom this.mouseWheel = function(event) { if (self.zoomEntityData) { self.zoomEntityData.updateSuperZoom(event.delta); } } // This function aborts the zoom loop this.abort = function() { self.zoomEntityData.abort(); self.zoomEntityData = undefined; if (self.connected) { self.disconnect(); } } // This function refits the selected entity on the screen when the screen dimensions change this.geometryChanged = function() { if (self.zoomEntityData !== undefined){ self.zoomEntityData.resetZoomAspect(); } } // This function aborts the zoom loop if the camera mode is set by the user this.modeUpdated = function(mode) { if (self.zoomEntityData && !self.zoomEntityData.ownCameraChange) { self.abort(); } } // This function initiate the connections this.init = function() { // Connect signals Window.geometryChanged.connect(self.geometryChanged); Camera.modeUpdated.connect(self.modeUpdated); Controller.mousePressEvent.connect(self.mousePressEvent); Controller.mouseDoublePressEvent.connect(self.mouseDoublePressEvent); Controller.mouseMoveEvent.connect(self.mouseMoveEvent); Controller.mouseReleaseEvent.connect(self.mouseReleaseEvent); Controller.wheelEvent.connect(self.mouseWheel); } // This function finish the connections this.scriptEnding = function() { // Disconnect on exit Window.geometryChanged.disconnect(self.geometryChanged); Camera.modeUpdated.disconnect(self.modeUpdated); Controller.mousePressEvent.disconnect(self.mousePressEvent); Controller.mouseDoublePressEvent.disconnect(self.mouseDoublePressEvent); Controller.mouseMoveEvent.disconnect(self.mouseMoveEvent); Controller.mouseReleaseEvent.disconnect(self.mouseReleaseEvent); Controller.wheelEvent.disconnect(self.mouseWheel); if (self.zoomEntityData) { self.abort(); } } } var zoomOE = new ZoomOnAnything(); zoomOE.init(); Script.scriptEnding.connect(zoomOE.scriptEnding); })();