diff --git a/@here/harp-mapview/lib/CameraUtils.ts b/@here/harp-mapview/lib/CameraUtils.ts index 6f93daf36e..1aa346b045 100644 --- a/@here/harp-mapview/lib/CameraUtils.ts +++ b/@here/harp-mapview/lib/CameraUtils.ts @@ -4,44 +4,212 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Vector2Like } from "@here/harp-geoutils"; +import { assert } from "@here/harp-utils"; import * as THREE from "three"; import { MAX_FOV_RAD, MIN_FOV_RAD } from "./FovCalculation"; +// In centered projections the principal point is at NDC origin, splitting vertical and horizontal +// fovs in two equal halves. +function isCenteredProjection(principalPoint: Vector2Like): boolean { + return principalPoint.x === 0 && principalPoint.y === 0; +} + +/** + * Computes the fov on the positive side of NDC x or y dimension (i.e. either right or top fov). + * @param focalLength - Focal length in pixels. It must be larger than 0. + * @param ppOffset - Principal point NDC offset either in y or x dimension. + * @param viewportSide - Viewport height or width in pixels, must be same dimension as ppOffset. + * @returns side fov in radians. + */ +function computePosSideFov(focalLength: number, ppOffset: number, viewportSide: number): number { + // see diagram in computeFocalLengthFromFov(). + assert(focalLength > 0, "Focal length must be larger than 0"); + return Math.atan(((1 - ppOffset) * viewportSide * 0.5) / focalLength); +} + +/** + * Computes the vertical or horizontal fov. + * @param focalLength - Focal length in pixels. It must be larger than 0. + * @param ppOffset - Principal point NDC offset in y (vertical fov) or x dimension (horizontal fov). + * @param viewportSide - Viewport height or width in pixels, must be same dimension as ppOffset. + * @returns vertical or horizontal fov in radians. + */ +function computeFov(focalLength: number, ppOffset: number, viewportSide: number): number { + assert(focalLength > 0, "Focal length must be larger than 0"); + // For uncentered fov, compute the two fov sides separately. The fov on the negative NDC + // side is computed in the same way as that for the positive side but flipping the offset sign. + return ppOffset === 0 + ? 2 * Math.atan((0.5 * viewportSide) / focalLength) + : computePosSideFov(focalLength, ppOffset, viewportSide) + + computePosSideFov(focalLength, -ppOffset, viewportSide); +} + +/** + * For off-center projections, fovs are asymmetric. In that case, the camera saves some of the side + * fovs to save some computations. All values are in radians. + */ +interface Fovs { + top: number; + right: number; + horizontal: number; // left = horizontal - right. +} + +function getFovs(camera: THREE.PerspectiveCamera): Fovs | undefined { + return camera.userData.fovs; +} + +/** + * Saves the camera focal length. For off-center projections, saves side fovs as well. + */ +function setCameraParams( + camera: THREE.PerspectiveCamera, + ppalPoint: Vector2Like, + focalLength: number, + height: number, + hFov: number +): void { + if (isCenteredProjection(ppalPoint)) { + delete camera.userData.fovs; + } else { + const width = height * camera.aspect; + camera.userData.fovs = { + top: computePosSideFov(focalLength, ppalPoint.y, height), + right: computePosSideFov(focalLength, ppalPoint.x, width), + horizontal: hFov + } as Fovs; + } + camera.userData.focalLength = focalLength; +} + +/** + * Computes a camera's focal length from vertical fov and viewport height or horizontal fov and + * viewport width. + * @beta + * + * @param fov - Vertical or horizontal field of view in radians. + * @param viewportSide - Viewport height if fov is vertical, otherwise viewport width. + * @param ppOffset - Principal point offset in y direction if fov is vertical, + * otherwise in x direction. + * @returns focal length in pixels. + */ +function computeFocalLengthFromFov(fov: number, viewportSide: number, ppOffset: number): number { + // C <- Projection center + // /|-_ + // / | -_ pfov = fov along positive NDC side (tfov or rfov) + // / | -_ nfov = fov along negative NDC side (bfov or lfov) + // / | -_ + // / | -_ + // /pfov | nfov -_ + // / | -_ + // / | -_ + // a / |focal length(f) -_ b + // / | -_ + // / | Principal point -_ + // / | / (pp) -_ + // A/____________P________________________-_B Viewport + // <------------><------------------------> + // (1-ppOff)*s/2 (1+ppOff)*s/2 + // <--------------------------------------> + // s = viewportSide (height or width) + // + // Diagram of fov splitting (potentially asymmetric) along a viewport side (height or width). + // For viewport height, fov is split into top (tfov) and bottom (bfov) fovs. For width, it's + // split into right fov (rfov) and left fov (lfov). + + // Case 1. Symmetric fov split. Principal point is centered (centered projection): + const halfSide = viewportSide / 2; + const ppCentered = ppOffset === 0; + if (ppCentered) { + return halfSide / Math.tan(fov / 2); + } + + // Case 2. Asymmetric fov split. Off-center perspective projection: + const eps = 1e-6; + const ppOffsetSq = ppOffset ** 2; + + if (Math.abs(fov - Math.PI / 2) < eps) { + // Case 2a. Special case for (close to) right angle fov, tangent approaches infinity: + // 3 right triangles: ACB, APC, BPC. Use pythagorean theorem on each to get 3 equations: + // a^2 = f^2 + (1-ppOff)*s/2 + // b^2 = f^2 + (1+ppOff)*s/2 + // h^2 = a^2 + b^2 + // Substitute a^2 and b^2 in third equation and solve for f to get: + // f = (s/2) * sqrt(1-ppOff^2) + return halfSide * Math.sqrt(1 - ppOffsetSq); + } + + // Case 2b. General asymmetric fov case: + // (1) tan(pfov) = (1-ppOff)*s / (2*f) + // (2) tan(nfov) = (1+ppOff)*s / (2*f) + // Use formula for the tan of the sum of two angles: + // (3) tan(fov) = tan(pfov+nfov) = (tan(pfov) + tan(nfov)) / (1 - (tan(pfov) * tan(nfov))) + + // Substitute (1) and (2) in (3) and solve for f to get a quadratic equation: + // 4*(tanf)^2 - 4*s*f - tan(1-ppOff^2)*s^2 = 0 , solving for f: + // f = (s/2) * (1 +/- sqrt(1 + tan(1-ppOff^2)^2)) / tan(fov) + + // ppOff (principal point offset) is in [-1,1], so there's two real solutions (radicant is >=1) + // and we choose the positive solution on each case: + // a) tan(fov) > 0, fov in (0,pi/2) -> f = (s/2) * (1 + sqrt(1 + tan(1-ppOff^2)^2)) / tan(fov) + // b) tan(fov) < 0, fov in (pi/2,pi) -> f = (s/2) * (1 - sqrt(1 + tan(1-ppOff^2)^2)) / tan(fov) + + const tanFov = Math.tan(fov); + const sign = Math.sign(tanFov); + const sqrt = Math.sqrt(1 + tanFov ** 2 * (1 - ppOffsetSq)); + const f = (halfSide * (1 + sign * sqrt)) / tanFov; + assert(f >= 0, "Focal length must be larger than 0"); + return f; +} + export namespace CameraUtils { /** - * Computes a camera's vertical field of view for given focal length and viewport height. + * Returns the camera's focal length. * @beta * - * @param focalLength - Focal length in pixels (see {@link computeFocalLength}) - * @param height - Viewport height in pixels. - * @returns Vertical field of view in radians. + * @param camera - The camera. + * @returns The focal length in pixels. */ - export function computeVerticalFov(focalLength: number, height: number): number { - return 2 * Math.atan(height / 2 / focalLength); + export function getFocalLength(camera: THREE.PerspectiveCamera): number { + return camera.userData.focalLength; } /** - * Computes a camera's horizontal field of view. + * Computes a camera's focal length from vertical fov and viewport height. * @beta * * @param camera - * @returns Horizontal field of view in radians. + * @param verticalFov - Vertical field of view in radians. It'll be clamped to + * [{@link MIN_FOV_RAD}, {@link MAX_FOV_RAD}]. + * @param height - Viewport height in pixels. + * @returns focal length in pixels. */ - export function computeHorizontalFov(camera: THREE.PerspectiveCamera): number { - const vFov = THREE.MathUtils.degToRad(camera.fov); - return 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect); + export function computeFocalLength( + camera: THREE.PerspectiveCamera, + verticalFov: number, + height: number + ): number { + verticalFov = THREE.MathUtils.clamp(verticalFov, MIN_FOV_RAD, MAX_FOV_RAD); + return computeFocalLengthFromFov(verticalFov, height, getPrincipalPoint(camera).y); } /** - * Set a camera's horizontal field of view. - * @internal + * Computes a camera's vertical field of view for given focal length and viewport height. + * @beta * * @param camera - * @param hFov - The horizontal field of view in radians. + * @param focalLength - Focal length in pixels (see {@link computeFocalLength}). It must be + * larger than 0. + * @param height - Viewport height in pixels. + * @returns Vertical field of view in radians. */ - export function setHorizontalFov(camera: THREE.PerspectiveCamera, hFov: number): void { - camera.fov = THREE.MathUtils.radToDeg(2 * Math.atan(Math.tan(hFov / 2) / camera.aspect)); + export function computeVerticalFov( + camera: THREE.PerspectiveCamera, + focalLength: number, + height: number + ): number { + return computeFov(focalLength, getPrincipalPoint(camera).y, height); } /** @@ -49,29 +217,31 @@ export namespace CameraUtils { * @internal * * @param camera - * @param fov - The vertical field of view in radians. + * @param fov - The vertical field of view in radians. It'll be clamped to + * [{@link MIN_FOV_RAD}, {@link MAX_FOV_RAD}]. + * @param focalLength - Focal length in pixels. It must be grater than 0. + * @param height - Viewport height in pixels. */ - export function setVerticalFov(camera: THREE.PerspectiveCamera, fov: number): void { + export function setVerticalFovAndFocalLength( + camera: THREE.PerspectiveCamera, + fov: number, + focalLength: number, + height: number + ): void { camera.fov = THREE.MathUtils.radToDeg(THREE.MathUtils.clamp(fov, MIN_FOV_RAD, MAX_FOV_RAD)); + const width = height * camera.aspect; + const ppalPoint = getPrincipalPoint(camera); - let hFov = computeHorizontalFov(camera); + let hFov = computeFov(focalLength, ppalPoint.x, width); if (hFov > MAX_FOV_RAD || hFov < MIN_FOV_RAD) { hFov = THREE.MathUtils.clamp(hFov, MIN_FOV_RAD, MAX_FOV_RAD); - setHorizontalFov(camera, hFov); + focalLength = computeFocalLengthFromFov(hFov, width, ppalPoint.x); + camera.fov = THREE.MathUtils.radToDeg( + computeVerticalFov(camera, focalLength, width / camera.aspect) + ); } - } - - /** - * Computes a camera's focal length for a given viewport height. - * @beta - * - * @param vFov - Vertical field of view in radians. - * @param height - Viewport height in pixels. - * @returns focal length in pixels. - */ - export function computeFocalLength(vFov: number, height: number): number { - return height / 2 / Math.tan(vFov / 2); + setCameraParams(camera, ppalPoint, focalLength, height, hFov); } /** @@ -107,4 +277,150 @@ export namespace CameraUtils { ): number { return (distance * screenSize) / focalLength; } + + /** + * Returns the camera's principal point (intersection of principal ray and image plane) + * in NDC coordinates. + * @beta + * @see https://en.wikipedia.org/wiki/Pinhole_camera_model + * @remarks This point coincides with the principal vanishing point. By default it's located at + * the image center (NDC coords [0,0]), and the resulting projection is centered or symmetric. + * But it may be offset (@see THREE.PerspectiveCamera.setViewOffset) for some use cases such as + * multiview setups (e.g. stereoscopic rendering), resulting in an asymmetric perspective + * projection. + * @param camera - The camera. + * @param result - Optional vector where the principal point coordinates will be copied. + * @returns A vector containing the principal point NDC coordinates. + */ + export function getPrincipalPoint( + camera: THREE.PerspectiveCamera, + result: Vector2Like = new THREE.Vector2() + ): Vector2Like { + result.x = -camera.projectionMatrix.elements[8]; + result.y = -camera.projectionMatrix.elements[9]; + return result; + } + + /** + * Sets the camera's principal point (intersection of principal ray and image plane) + * in NDC coordinates. + * @beta + * @see {@link getPrincipalPoint} + * @param camera - The camera. + * @param ndcCoords - The principal point's NDC coordinates, each coordinate can have values in + * the open interval (-1,1). + */ + export function setPrincipalPoint(camera: THREE.PerspectiveCamera, ndcCoords: Vector2Like) { + // We only need to set to proper elements in the projection matrix: + // camera.projectionMatrix.elements[8] = -ndcCoords.x + // camera.projectionMatrix.elements[9] = -ndcCoords.y + // However, this can't be done directly, otherwise it'd be overwritten on the next call to + // camera.updateProjectionMatrix(). The only way to set the principal point is through a + // THREE.js camera method for multi-view setup, see: + // https://threejs.org/docs/#api/en/cameras/PerspectiveCamera.setViewOffset + const height = 1; + const width = camera.aspect; + + // Principal point splits fov in two angles that must be strictly less than 90 degrees + // (each one belongs to a right triangle). Setting the principal point at the edges (-1 or + // 1) would make it impossible to achieve an fov >= 90. Thus, clamp the principal point + // coordinates to values slightly smaller than 1. + const maxNdcCoord = 1 - 1e-6; + camera.setViewOffset( + width, + height, + (-THREE.MathUtils.clamp(ndcCoords.x, -maxNdcCoord, maxNdcCoord) * width) / 2, + (THREE.MathUtils.clamp(ndcCoords.y, -maxNdcCoord, maxNdcCoord) * height) / 2, + width, + height + ); + } + + /** + * Returns the camera's vertical field of view. + * @param camera - The camera. + * @returns The vertical fov in radians. + */ + export function getVerticalFov(camera: THREE.PerspectiveCamera): number { + return THREE.MathUtils.degToRad(camera.fov); + } + + /** + * Returns the camera's horizontal field of view. + * @param camera - The camera. + * @returns The horizontal fov in radians. + */ + export function getHorizontalFov(camera: THREE.PerspectiveCamera): number { + // If horizontal fov is not stored in camera, assume centered projection and compute + // it from the vertical fov. + return ( + getFovs(camera)?.horizontal ?? + 2 * Math.atan(Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2) * camera.aspect) + ); + } + + /** + * Returns top fov angle for a given perspective camera. + * @beta + * @remarks In symmetric projections, the principal point coincides with the image center, and + * the vertical and horizontal FOVs are each split at that point in two equal halves. + * However, in asymmetric projections the principal point is not at the image center, and thus + * each fov is split unevenly in two parts: + * + * Symmetric projection Asymmetric projection + * ------------------------- -------------------------- + * | ^ | | ^ | + * | | | | |tFov | + * | |tFov | | lFov v rFov | + * | | | |<----->x<-------------->| + * | lFov v rFov | | ppal ^ point | + * |<--------->x<--------->| | | o | + * | ppal point=img center | | | img center | + * | ^ | | | | + * | |bFov | | |bFov | + * | | | | | | + * | v | | v | + * ------------------------- -------------------------- + * + * @param camera - The camera. + * @returns The top fov angle in radians. + */ + export function getTopFov(camera: THREE.PerspectiveCamera): number { + return getFovs(camera)?.top ?? THREE.MathUtils.degToRad(camera.fov / 2); + } + + /** + * Returns bottom fov angle for a given perspective camera. + * @see {@link CameraUtils.getTopFov} + * @beta + * @param camera - The camera. + * @returns The bottom fov angle in radians. + */ + export function getBottomFov(camera: THREE.PerspectiveCamera): number { + return THREE.MathUtils.degToRad(camera.fov) - getTopFov(camera); + } + + /** + * Returns right fov angle for a given perspective camera. + * @see {@link CameraUtils.getTopFov} + * @beta + * @param camera - The camera. + * @returns The right fov angle in radians. + */ + export function getRightFov(camera: THREE.PerspectiveCamera): number { + return getFovs(camera)?.right ?? getHorizontalFov(camera) / 2; + } + + /** + * Returns left fov angle for a given perspective camera. + * @see {@link CameraUtils.getTopFov} + * @beta + * @param camera - The camera. + * @returns The left fov angle in radians. + */ + export function getLeftFov(camera: THREE.PerspectiveCamera): number { + return getFovs(camera)?.right !== undefined + ? getHorizontalFov(camera) - getRightFov(camera) + : getHorizontalFov(camera) / 2; + } } diff --git a/@here/harp-mapview/lib/ClipPlanesEvaluator.ts b/@here/harp-mapview/lib/ClipPlanesEvaluator.ts index 8a7a5a573b..e4c7e6106a 100644 --- a/@here/harp-mapview/lib/ClipPlanesEvaluator.ts +++ b/@here/harp-mapview/lib/ClipPlanesEvaluator.ts @@ -100,13 +100,13 @@ namespace SphericalProj { * Calculate distance to the nearest point where the near plane is tangent to the sphere, * projected onto the camera forward vector. * @param camera - The camera. - * @param halfVerticalFovAngle + * @param bottomFov - Angle from camera forward vector to frustum bottom plane. * @param R - The sphere radius. * @returns The tangent point distance if the point is visible, otherwise `undefined`. */ export function getProjNearPlaneTanDistance( camera: THREE.Camera, - halfVerticalFovAngle: number, + bottomFov: number, R: number ): number | undefined { // ,^;;;;;;;;;;;;;;;;- @@ -143,7 +143,7 @@ namespace SphericalProj { const cosTanDirToFwdDir = near / camToTanVec.length(); // Tangent point visible if angle from fwdDir to tangent is less than to frustum bottom. - return cosTanDirToFwdDir > Math.cos(halfVerticalFovAngle) ? near : undefined; + return cosTanDirToFwdDir > Math.cos(bottomFov) ? near : undefined; } /** @@ -477,7 +477,10 @@ export class TopViewClipPlanesEvaluator extends ElevationBasedClipPlanesEvaluato let halfFovAngle = THREE.MathUtils.degToRad(camera.fov / 2); // If width > height, then we have to compute the horizontal FOV. if (camera.aspect > 1) { - halfFovAngle = CameraUtils.computeHorizontalFov(camera); + halfFovAngle = MapViewUtils.calculateHorizontalFovByVerticalFov( + halfFovAngle * 2, + camera.aspect + ); } const maxR = r + this.maxElevation; @@ -591,20 +594,18 @@ export class TopViewClipPlanesEvaluator extends ElevationBasedClipPlanesEvaluato * angle (angle between look-at vector and ground surface normal). */ export class TiltViewClipPlanesEvaluator extends TopViewClipPlanesEvaluator { - private static readonly NDC_BOTTOM_DIR = new THREE.Vector2(0, -1); - private static readonly NDC_TOP_RIGHT_DIR = new THREE.Vector2(1, 1); + private readonly m_tmpV2 = new THREE.Vector2(); - /** - * Calculate distances to top/bottom frustum plane intersections with the ground plane. - * - * @param camera - The perspective camera. - * @param projection - The geo-projection used to convert geographic to world coordinates. - */ - private getFrustumGroundIntersectionDist( + /** @override */ + protected evaluateDistancePlanarProj( camera: THREE.PerspectiveCamera, - projection: Projection - ): { top: number; bottom: number } { - assert(projection.type !== ProjectionType.Spherical); + projection: Projection, + elevationProvider?: ElevationProvider + ): ViewRanges { + // Find intersections of top/bottom frustum side's medians with the ground plane, taking + // into account min/max elevations.Top side intersection distance determines the far + // distance (it's further away than bottom intersection on tilted views), and bottom side + // intersection distance determines the near distance. // 🎥 // C // |\ @@ -636,51 +637,43 @@ export class TiltViewClipPlanesEvaluator extends TopViewClipPlanesEvaluator { // // The intersection distances to be found are |c1| (bottom plane) and |c2| (top plane). + assert(projection.type !== ProjectionType.Spherical); + const viewRanges = { ...this.minimumViewRange }; const halfPiLimit = Math.PI / 2 - epsilon; const z = projection.groundDistance(camera.position); const cameraTilt = MapViewUtils.extractCameraTilt(camera, projection); - // Angle between top/bottom plane and eye vector is half of the vertical fov. - const halfFov = THREE.MathUtils.degToRad(camera.fov / 2); + + // Angles between top/bottom plane and eye vector. For centered projections both are equal + // to half of the vertical fov. + const topFov = CameraUtils.getTopFov(camera); + const bottomFov = CameraUtils.getBottomFov(camera); // Angle between z and c2 - const topAngle = THREE.MathUtils.clamp(cameraTilt + halfFov, -halfPiLimit, halfPiLimit); + const topAngle = THREE.MathUtils.clamp(cameraTilt + topFov, -halfPiLimit, halfPiLimit); // Angle between z and c1 - const bottomAngle = THREE.MathUtils.clamp(cameraTilt - halfFov, -halfPiLimit, halfPiLimit); + const bottomAngle = THREE.MathUtils.clamp( + cameraTilt - bottomFov, + -halfPiLimit, + halfPiLimit + ); // Compute |c2|. This will determine the far distance (top intersection is further away than // bottom intersection on tilted views), so take the furthest distance possible, i.e.the // distance to the min elevation. // cos(topAngle) = (z2 - minElev) / |c2| // |c2| = (z2 - minElev) / cos(topAngle) - const topDist = (z - this.minElevation) / Math.cos(topAngle); + const topDist = Math.max(0, (z - this.minElevation) / Math.cos(topAngle)); // Compute |c1|. This will determine the near distance, so take the nearest distance // possible, i.e.the distance to the max elevation. - const bottomDist = (z - this.maxElevation) / Math.cos(bottomAngle); - - return { top: Math.max(topDist, 0), bottom: Math.max(bottomDist, 0) }; - } - - /** @override */ - protected evaluateDistancePlanarProj( - camera: THREE.PerspectiveCamera, - projection: Projection, - elevationProvider?: ElevationProvider - ): ViewRanges { - assert(projection.type !== ProjectionType.Spherical); - const viewRanges = { ...this.minimumViewRange }; - - // Find the distances to the top/bottom frustum plane intersections with the ground plane. - const planesDist = this.getFrustumGroundIntersectionDist(camera, projection); + const bottomDist = Math.max(0, (z - this.maxElevation) / Math.cos(bottomAngle)); // Project intersection distances onto the eye vector. - // Angle between top/bottom plane and eye vector is half of the vertical fov. - const halfFov = THREE.MathUtils.degToRad(camera.fov / 2); - const cosHalfFov = Math.cos(halfFov); // cos(halfFov) = near / bottomDist // near = cos(halfFov) * bottomDist - viewRanges.near = planesDist.bottom * cosHalfFov; + viewRanges.near = bottomDist * Math.cos(bottomFov); // cos(halfFov) = far / topDist // far = cos(halfFov) * topDist - viewRanges.far = planesDist.top * cosHalfFov; + viewRanges.far = topDist * Math.cos(topFov); + return this.applyViewRangeConstraints(viewRanges, camera, projection, elevationProvider); } @@ -692,11 +685,9 @@ export class TiltViewClipPlanesEvaluator extends TopViewClipPlanesEvaluator { ): ViewRanges { assert(projection.type === ProjectionType.Spherical); const viewRanges = { ...this.minimumViewRange }; - assert(camera instanceof THREE.PerspectiveCamera, "Unsupported camera type."); - const perspectiveCam = camera as THREE.PerspectiveCamera; - viewRanges.near = this.computeNearDistSphericalProj(perspectiveCam, projection); - viewRanges.far = this.computeFarDistSphericalProj(perspectiveCam, projection); + viewRanges.near = this.computeNearDistSphericalProj(camera, projection); + viewRanges.far = this.computeFarDistSphericalProj(camera, projection); return this.applyViewRangeConstraints(viewRanges, camera, projection, elevationProvider); } @@ -715,17 +706,38 @@ export class TiltViewClipPlanesEvaluator extends TopViewClipPlanesEvaluator { return 0; } - const perspectiveCam = camera as THREE.PerspectiveCamera; - const halfVerticalFovAngle = THREE.MathUtils.degToRad(perspectiveCam.fov / 2); const maxR = EarthConstants.EQUATORIAL_RADIUS + this.maxElevation; - const ndcBottomDir = TiltViewClipPlanesEvaluator.NDC_BOTTOM_DIR; - - // Use as near the distance of the near plane's tangent point to the sphere. If not visible, - // use the distance to then bottom frustum side intersection. - const near = - SphericalProj.getProjNearPlaneTanDistance(camera, halfVerticalFovAngle, maxR) ?? - SphericalProj.getProjSphereIntersectionDistance(camera, ndcBottomDir, maxR); - assert(near !== undefined, "No reference point for near distance found"); + + // Angles between bottom plane and eye vector. For centered projections it's equal to half + // of the vertical fov. + const bottomFov = CameraUtils.getBottomFov(camera); + + // First, use the distance of the near plane's tangent point to the sphere. + const nearPlaneTanDist = SphericalProj.getProjNearPlaneTanDistance(camera, bottomFov, maxR); + if (nearPlaneTanDist !== undefined) { + return nearPlaneTanDist; + } + // If near plan tangent is not visible, use the distance to the closest frustum intersection + // with the sphere. If principal point has a y offset <= 0, bottom frustum intersection + // is at same distance or closer than top intersection, otherwise both need to be checked. + // At least one of the sides must intersect, if not the near plane tangent must have been + // visible. + CameraUtils.getPrincipalPoint(camera, this.m_tmpV2); + const checkTopIntersection = this.m_tmpV2.y > 0; + const bottomDist = SphericalProj.getProjSphereIntersectionDistance( + camera, + this.m_tmpV2.setComponent(1, -1), + maxR + ); + const topDist = checkTopIntersection + ? SphericalProj.getProjSphereIntersectionDistance( + camera, + this.m_tmpV2.setComponent(1, 1), + maxR + ) + : Infinity; + const near = Math.min(bottomDist ?? Infinity, topDist ?? Infinity); + assert(near !== Infinity, "No reference point for near distance found"); return near ?? defaultNear; } @@ -738,15 +750,36 @@ export class TiltViewClipPlanesEvaluator extends TopViewClipPlanesEvaluator { const minR = r + this.minElevation; const maxR = r + this.maxElevation; const d = camera.position.length(); - const ndcTopRightDir = TiltViewClipPlanesEvaluator.NDC_TOP_RIGHT_DIR; - - // If the top right frustum edge intersects the world, use as far distance the distance to - // the intersection projected on the eye vector. Otherwise, use the distance of the horizon - // at the maximum elevation. - return ( - SphericalProj.getProjSphereIntersectionDistance(camera, ndcTopRightDir, minR) ?? - SphericalProj.getFarDistanceFromElevatedHorizon(camera, d, r, maxR) + + // If all frustum edges intersect the world, use as far distance the distance to the + // farthest intersection projected on eye vector. If principal point has a y offset <= 0, + // top frustum intersection is at same distance or farther than bottom intersection, + // otherwise both need to be checked. + CameraUtils.getPrincipalPoint(camera, this.m_tmpV2); + const isRightIntersectionFarther = this.m_tmpV2.x <= 0.0; + const ndcX = isRightIntersectionFarther ? 1 : -1; + const checkBottomIntersection = this.m_tmpV2.y > 0; + + const topDist = SphericalProj.getProjSphereIntersectionDistance( + camera, + this.m_tmpV2.set(ndcX, 1), + minR ); + const bottomDist = checkBottomIntersection + ? SphericalProj.getProjSphereIntersectionDistance( + camera, + this.m_tmpV2.set(ndcX, -1), + minR + ) + : 0; + const largestDist = Math.max(topDist ?? Infinity, bottomDist ?? Infinity); + if (largestDist !== Infinity) { + return largestDist; + } + + // If any frustum edge does not intersect (i.e horizon is visible in that viewport corner), + // use the horizon distance at the maximum elevation. + return SphericalProj.getFarDistanceFromElevatedHorizon(camera, d, r, maxR); } private applyViewRangeConstraints( diff --git a/@here/harp-mapview/lib/MapView.ts b/@here/harp-mapview/lib/MapView.ts index 444a01679f..3a1252e7fb 100644 --- a/@here/harp-mapview/lib/MapView.ts +++ b/@here/harp-mapview/lib/MapView.ts @@ -3868,10 +3868,10 @@ export class MapView extends EventDispatcher { fov = THREE.MathUtils.degToRad(fovCalculation.fov); } else { assert(this.m_focalLength !== 0); - fov = CameraUtils.computeVerticalFov(this.m_focalLength, height); + fov = CameraUtils.computeVerticalFov(this.m_camera, this.m_focalLength, height); } - CameraUtils.setVerticalFov(this.m_camera, fov); + CameraUtils.setVerticalFovAndFocalLength(this.m_camera, fov, this.m_focalLength, height); } /** @@ -3885,6 +3885,7 @@ export class MapView extends EventDispatcher { private updateFocalLength(height: number) { assert(this.m_options.fovCalculation !== undefined); this.m_focalLength = CameraUtils.computeFocalLength( + this.m_camera, THREE.MathUtils.degToRad(this.m_options.fovCalculation!.fov), height ); diff --git a/@here/harp-mapview/lib/SphereHorizon.ts b/@here/harp-mapview/lib/SphereHorizon.ts index 33f4be134e..bdfa3c4942 100644 --- a/@here/harp-mapview/lib/SphereHorizon.ts +++ b/@here/harp-mapview/lib/SphereHorizon.ts @@ -366,16 +366,17 @@ export class SphereHorizon { return this.m_cameraPitch; } + // TODO: Support off-center projections. private get halfFovVertical(): number { if (this.m_halfFovVertical === undefined) { - this.m_halfFovVertical = THREE.MathUtils.degToRad(this.m_camera.fov / 2); + this.m_halfFovVertical = CameraUtils.getVerticalFov(this.m_camera) / 2; } return this.m_halfFovVertical; } private get halfFovHorizontal(): number { if (this.m_halfFovHorizontal === undefined) { - this.m_halfFovHorizontal = CameraUtils.computeHorizontalFov(this.m_camera) / 2; + this.m_halfFovHorizontal = CameraUtils.getHorizontalFov(this.m_camera) / 2; } return this.m_halfFovHorizontal; } diff --git a/@here/harp-mapview/lib/Utils.ts b/@here/harp-mapview/lib/Utils.ts index 0adf6d5a3f..bf5f4d0159 100644 --- a/@here/harp-mapview/lib/Utils.ts +++ b/@here/harp-mapview/lib/Utils.ts @@ -907,7 +907,8 @@ export namespace MapViewUtils { cameraPos.copy(camera.position); const halfVertFov = THREE.MathUtils.degToRad(camera.fov / 2); - const halfHorzFov = CameraUtils.computeHorizontalFov(camera) / 2; + // TODO: Support off-center projections. + const halfHorzFov = calculateHorizontalFovByVerticalFov(halfVertFov * 2, camera.aspect) / 2; // tan(fov/2) const halfVertFovTan = 1 / Math.tan(halfVertFov); @@ -1622,32 +1623,40 @@ export namespace MapViewUtils { * @deprecated */ export function calculateVerticalFovByHorizontalFov(hFov: number, aspect: number): number { - tmpCamera.aspect = aspect; - CameraUtils.setHorizontalFov(tmpCamera, hFov); - return THREE.MathUtils.degToRad(tmpCamera.fov); + return 2 * Math.atan(Math.tan(hFov / 2) / aspect); } /** - * @deprecated Use {@link CameraUtils.computeHorizontalFov}. + * @deprecated Use {@link CameraUtils.getHorizontalFov}. */ export function calculateHorizontalFovByVerticalFov(vFov: number, aspect: number): number { tmpCamera.fov = THREE.MathUtils.radToDeg(vFov); tmpCamera.aspect = aspect; - return CameraUtils.computeHorizontalFov(tmpCamera); + return CameraUtils.getHorizontalFov(tmpCamera); } /** * @deprecated Use {@link CameraUtils.computeFocalLength}. */ export function calculateFocalLengthByVerticalFov(vFov: number, height: number): number { - return CameraUtils.computeFocalLength(vFov, height); + // computeVerticalFov takes into account the principal point position to support + // off-center projections. Keep previous behaviour by passing a camera with centered + // principal point. + CameraUtils.setPrincipalPoint(tmpCamera, new THREE.Vector2()); + return CameraUtils.computeFocalLength(tmpCamera, vFov, height); } /** * @deprecated Use {@link CameraUtils.computeVerticalFov}. */ export function calculateFovByFocalLength(focalLength: number, height: number): number { - return THREE.MathUtils.radToDeg(CameraUtils.computeVerticalFov(focalLength, height)); + // computeVerticalFov takes into account the principal point position to support + // off-center projections. Keep previous behaviour by passing a camera with centered + // principal point. + CameraUtils.setPrincipalPoint(tmpCamera, new THREE.Vector2()); + return THREE.MathUtils.radToDeg( + CameraUtils.computeVerticalFov(tmpCamera, focalLength, height) + ); } /** diff --git a/@here/harp-mapview/test/CameraUtilsTest.ts b/@here/harp-mapview/test/CameraUtilsTest.ts index dd6d7759ba..fad8bc06b7 100644 --- a/@here/harp-mapview/test/CameraUtilsTest.ts +++ b/@here/harp-mapview/test/CameraUtilsTest.ts @@ -8,14 +8,177 @@ import { expect } from "chai"; import * as THREE from "three"; import { CameraUtils } from "../lib/CameraUtils"; +import { MAX_FOV_RAD, MIN_FOV_RAD } from "../lib/FovCalculation"; // Mocha discourages using arrow functions, see https://mochajs.org/#arrow-functions describe("CameraUtils", function () { - it("compute horizontal and vertical fov", function () { - const vFov = 60; - const aspect = 0.9; - const camera = new THREE.PerspectiveCamera(vFov, aspect); - CameraUtils.setHorizontalFov(camera, CameraUtils.computeHorizontalFov(camera)); - expect(camera.fov).to.be.closeTo(vFov, 1e-11); + let camera: THREE.PerspectiveCamera; + + beforeEach(function () { + camera = new THREE.PerspectiveCamera(); + }); + + it("computeFocalLength clamps fov between min and max values", function () { + const height = 1080; + expect(CameraUtils.computeFocalLength(camera, 0.9 * MIN_FOV_RAD, height)).equals( + CameraUtils.computeFocalLength(camera, MIN_FOV_RAD, height) + ); + expect(CameraUtils.computeFocalLength(camera, 1.1 * MAX_FOV_RAD, height)).equals( + CameraUtils.computeFocalLength(camera, MAX_FOV_RAD, height) + ); + }); + + describe("computeFocalLength and computeVerticalFov", function () { + const tests: Array<[number, number, number, number]> = [ + // [fov (deg), height, ppal point y offset, expected focal length] + [15, 100, 0, 379.78771], + [45, 100, 0, 120.71068], + [60, 100, 0, 86.60254], + [90, 100, 0, 50.0], + [115, 100, 0, 31.85351], + [135, 100, 0, 20.71068], + + [15, 3000, 0, 11393.63117], + [45, 3000, 0, 3621.32034], + [60, 3000, 0, 2598.07621], + [90, 3000, 0, 1500.0], + [115, 3000, 0, 955.60539], + [135, 3000, 0, 621.32034], + + // off-center projection cases. + [15, 3000, 0.1, 11391.6897], + [45, 3000, 0.5, 3484.31348], + [60, 3000, 1, 1732.0534], + [89.9, 100, -0.5, 43.38862], + [89.9999999, 100, -0.5, 43.30127], + [90, 100, -0.5, 43.30127], + [90.0000001, 100, -0.5, 43.30127], + [90.1, 100, -0.1, 49.66218], + [115, 100, -1, 1.1e-4], + [135, 100, -1, 5e-5] + ]; + + for (const test of tests) { + const [fovDeg, height, ppOffsetY, focalLength] = test; + + it(`focal length ${focalLength}, fov ${fovDeg}, height ${height}, ppOffsetY ${ppOffsetY}`, function () { + const fov = THREE.MathUtils.degToRad(fovDeg); + CameraUtils.setPrincipalPoint(camera, { x: 0, y: ppOffsetY }); + + const actualFocalLength = CameraUtils.computeFocalLength(camera, fov, height); + expect(actualFocalLength).closeTo(focalLength, 1e-5); + + const actualVFov = CameraUtils.computeVerticalFov(camera, focalLength, height); + expect(actualVFov).closeTo(fov, 1e-2); + }); + } + }); + + describe("setVerticalFovAndFocalLength", function () { + it("sets correct settings for centered projections", function () { + camera.aspect = 1.1; + const height = 100; + const expectedVFov = Math.PI / 4; + const focalLength = CameraUtils.computeFocalLength(camera, expectedVFov, height); + CameraUtils.setVerticalFovAndFocalLength(camera, expectedVFov, focalLength, height); + + const eps = 1e-6; + expect(CameraUtils.getFocalLength(camera)).equals(focalLength); + expect(CameraUtils.getVerticalFov(camera)).closeTo(expectedVFov, eps); + expect(CameraUtils.getHorizontalFov(camera)).closeTo(0.85506, eps); + expect(CameraUtils.getTopFov(camera)) + .equals(CameraUtils.getBottomFov(camera)) + .and.equals(CameraUtils.getVerticalFov(camera) / 2); + expect(CameraUtils.getRightFov(camera)) + .equals(CameraUtils.getLeftFov(camera)) + .and.equals(CameraUtils.getHorizontalFov(camera) / 2); + }); + + it("sets correct settings for off-center projections", function () { + camera.aspect = 1.1; + const height = 100; + const expectedVFov = Math.PI / 4; + const focalLength = CameraUtils.computeFocalLength(camera, expectedVFov, height); + const ppalPoint = { x: 0.5, y: -0.1 }; + CameraUtils.setPrincipalPoint(camera, ppalPoint); + CameraUtils.setVerticalFovAndFocalLength(camera, expectedVFov, focalLength, height); + + const eps = 1e-6; + const top = CameraUtils.getTopFov(camera); + const bottom = CameraUtils.getBottomFov(camera); + const right = CameraUtils.getRightFov(camera); + const left = CameraUtils.getLeftFov(camera); + expect(CameraUtils.getFocalLength(camera)).equals(focalLength); + expect(CameraUtils.getVerticalFov(camera)).closeTo(expectedVFov, eps); + expect(CameraUtils.getHorizontalFov(camera)).closeTo(0.823528, eps); + expect(top).closeTo(0.42753, eps); + expect(bottom).closeTo(0.357868, eps); + expect(right).closeTo(0.223995, eps); + expect(left).closeTo(0.599534, eps); + expect(top + bottom).closeTo(CameraUtils.getVerticalFov(camera), eps); + expect(left + right).closeTo(CameraUtils.getHorizontalFov(camera), eps); + }); + + it("clamps vertical fov between min and max values", function () { + const height = 100; + const tooSmallFov = 0.9 * MIN_FOV_RAD; + CameraUtils.setVerticalFovAndFocalLength( + camera, + tooSmallFov, + CameraUtils.computeFocalLength(camera, tooSmallFov, height), + height + ); + expect(CameraUtils.getVerticalFov(camera)).equals(MIN_FOV_RAD); + + const tooLargeFov = 1.1 * MAX_FOV_RAD; + CameraUtils.setVerticalFovAndFocalLength( + camera, + tooLargeFov, + CameraUtils.computeFocalLength(camera, tooLargeFov, height), + height + ); + expect(CameraUtils.getVerticalFov(camera)).equals(MAX_FOV_RAD); + }); + + it("clamps horizontal fov between min and max values", function () { + const height = 100; + camera.aspect = 0.9; + CameraUtils.setVerticalFovAndFocalLength( + camera, + MIN_FOV_RAD, + CameraUtils.computeFocalLength(camera, MIN_FOV_RAD, height), + height + ); + expect(CameraUtils.getHorizontalFov(camera)).equals(MIN_FOV_RAD); + + camera.aspect = 1.1; + CameraUtils.setVerticalFovAndFocalLength( + camera, + MAX_FOV_RAD, + CameraUtils.computeFocalLength(camera, MAX_FOV_RAD, height), + height + ); + expect(CameraUtils.getHorizontalFov(camera)).equals(MAX_FOV_RAD); + }); + }); + + describe("setPrincipalPoint", function () { + it("sets the ppal point coordinates in the projection matrix", function () { + const ppOffset = { x: -0.42, y: 0.33 }; + CameraUtils.setPrincipalPoint(camera, ppOffset); + camera.updateProjectionMatrix(); + const actualPpOffset = CameraUtils.getPrincipalPoint(camera); + expect(actualPpOffset.x).closeTo(ppOffset.x, Number.EPSILON); + expect(actualPpOffset.y).closeTo(ppOffset.y, Number.EPSILON); + }); + + it("does not allow setting ppal point coordinates to -1 or 1", function () { + const ppOffset = { x: -1, y: 1 }; + CameraUtils.setPrincipalPoint(camera, ppOffset); + camera.updateProjectionMatrix(); + const actualPpOffset = CameraUtils.getPrincipalPoint(camera); + expect(actualPpOffset.x).gt(ppOffset.x).and.closeTo(ppOffset.x, 1e-3); + expect(actualPpOffset.y).lt(ppOffset.y).and.closeTo(ppOffset.y, 1e-3); + }); }); }); diff --git a/@here/harp-mapview/test/ClipPlanesEvaluatorTest.ts b/@here/harp-mapview/test/ClipPlanesEvaluatorTest.ts index 3e83bbaaf9..89ce055f5c 100644 --- a/@here/harp-mapview/test/ClipPlanesEvaluatorTest.ts +++ b/@here/harp-mapview/test/ClipPlanesEvaluatorTest.ts @@ -23,20 +23,23 @@ function setupPerspectiveCamera( projection: Projection, zoomLevel?: number, distance?: number, - tilt: number = 0 + tilt: number = 0, + principalPointNDC?: THREE.Vector2 ): THREE.PerspectiveCamera { - const vFov = 90; + const vFov = 40; + const vFovRad = THREE.MathUtils.degToRad(vFov); const camera = new THREE.PerspectiveCamera(vFov, 1, 1, 100); const geoTarget = new GeoCoordinates(0, 0); const heading = 0; + const canvasHeight = 500; - MapViewUtils.getCameraRotationAtTarget(projection, geoTarget, heading, tilt, camera.quaternion); + if (principalPointNDC) { + CameraUtils.setPrincipalPoint(camera, principalPointNDC); + } + const focalLength = CameraUtils.computeFocalLength(camera, vFovRad, canvasHeight); + CameraUtils.setVerticalFovAndFocalLength(camera, vFovRad, focalLength, canvasHeight); - const canvasHeight = 500; - const focalLength = CameraUtils.computeFocalLength( - THREE.MathUtils.degToRad(vFov), - canvasHeight - ); + MapViewUtils.getCameraRotationAtTarget(projection, geoTarget, heading, tilt, camera.quaternion); if (!distance) { expect(zoomLevel).to.not.be.undefined; distance = MapViewUtils.calculateDistanceFromZoomLevel({ focalLength }, zoomLevel ?? 1); @@ -59,92 +62,109 @@ interface ZoomLevelTest { zoomLevel: number; far: number; near: number; + ppalPointNDC?: [number, number]; } const mercatorZoomTruthTable: ZoomLevelTest[] = [ - { zoomLevel: 1, far: 20057066, near: 19077865 }, - { zoomLevel: 2, far: 10028528, near: 9538523 }, - { zoomLevel: 3, far: 5014259, near: 4768853 }, - { zoomLevel: 4, far: 2507124, near: 2384018 }, - { zoomLevel: 5, far: 1253557, near: 1191600 }, - { zoomLevel: 6, far: 626773, near: 595391 }, - { zoomLevel: 7, far: 313381, near: 297287 }, - { zoomLevel: 8, far: 156686, near: 148235 }, - { zoomLevel: 9, far: 78338, near: 73708 }, - { zoomLevel: 10, far: 39164, near: 36445 }, - { zoomLevel: 11, far: 19577, near: 17814 }, - { zoomLevel: 12, far: 9783, near: 8498 }, - { zoomLevel: 13, far: 4886, near: 3840 }, - { zoomLevel: 14, far: 2438, near: 1511 }, - { zoomLevel: 15, far: 1214, near: 347 }, - { zoomLevel: 16, far: 605, near: 1 }, - { zoomLevel: 17, far: 302, near: 1 }, - { zoomLevel: 18, far: 151, near: 1 }, - { zoomLevel: 19, far: 76, near: 1 }, - { zoomLevel: 20, far: 38, near: 1 }, - { tilt: 45, zoomLevel: 1, far: 118997158, near: 8193471 }, - { tilt: 45, zoomLevel: 2, far: 59498575, near: 4096447 }, - { tilt: 45, zoomLevel: 3, far: 29749284, near: 2047934 }, - { tilt: 45, zoomLevel: 4, far: 14874638, near: 1023678 }, - { tilt: 45, zoomLevel: 5, far: 7437316, near: 511550 }, - { tilt: 45, zoomLevel: 6, far: 3718654, near: 255486 }, - { tilt: 45, zoomLevel: 7, far: 1859323, near: 127454 }, - { tilt: 45, zoomLevel: 8, far: 929658, near: 63438 }, - { tilt: 45, zoomLevel: 9, far: 464825, near: 31430 }, - { tilt: 45, zoomLevel: 10, far: 232409, near: 15426 }, - { tilt: 45, zoomLevel: 11, far: 116201, near: 7424 }, - { tilt: 45, zoomLevel: 12, far: 58097, near: 3423 }, - { tilt: 45, zoomLevel: 13, far: 29045, near: 1422 }, - { tilt: 45, zoomLevel: 14, far: 14519, near: 422 }, - { tilt: 45, zoomLevel: 15, far: 7256, near: 1 }, - { tilt: 45, zoomLevel: 16, far: 3628, near: 1 }, - { tilt: 45, zoomLevel: 17, far: 1814, near: 1 }, - { tilt: 45, zoomLevel: 18, far: 907, near: 1 }, - { tilt: 45, zoomLevel: 19, far: 453, near: 1 }, - { tilt: 45, zoomLevel: 20, far: 227, near: 1 } + // Top down view tests. + { zoomLevel: 1, far: 53762306, near: 53762306 }, + { zoomLevel: 2, far: 26881153, near: 26881153 }, + { zoomLevel: 3, far: 13440577, near: 13440577 }, + { zoomLevel: 4, far: 6720288, near: 6720288 }, + { zoomLevel: 5, far: 3360144, near: 3360144 }, + { zoomLevel: 6, far: 1680072, near: 1680072 }, + { zoomLevel: 7, far: 840036, near: 840036 }, + { zoomLevel: 8, far: 420018, near: 420018 }, + { zoomLevel: 9, far: 210009, near: 210009 }, + { zoomLevel: 10, far: 105005, near: 105005 }, + { zoomLevel: 11, far: 52502, near: 52502 }, + { zoomLevel: 12, far: 26251, near: 26251 }, + { zoomLevel: 13, far: 13126, near: 13126 }, + { zoomLevel: 14, far: 6563, near: 6563 }, + { zoomLevel: 15, far: 3281, near: 3281 }, + { zoomLevel: 16, far: 1641, near: 1641 }, + { zoomLevel: 17, far: 820, near: 820 }, + { zoomLevel: 18, far: 410, near: 410 }, + { zoomLevel: 19, far: 205, near: 205 }, + { zoomLevel: 20, far: 103, near: 103 }, + + // Tilted view, horizon not visible. + { tilt: 45, zoomLevel: 1, far: 84527972, near: 39416041 }, + { tilt: 45, zoomLevel: 2, far: 42263986, near: 19708020 }, + { tilt: 45, zoomLevel: 3, far: 21131993, near: 9854010 }, + { tilt: 45, zoomLevel: 4, far: 10565997, near: 4927005 }, + { tilt: 45, zoomLevel: 5, far: 5282998, near: 2463503 }, + { tilt: 45, zoomLevel: 6, far: 2641499, near: 1231751 }, + { tilt: 45, zoomLevel: 7, far: 1320750, near: 615876 }, + { tilt: 45, zoomLevel: 8, far: 660375, near: 307938 }, + { tilt: 45, zoomLevel: 9, far: 330187, near: 153969 }, + { tilt: 45, zoomLevel: 10, far: 165094, near: 76984 }, + { tilt: 45, zoomLevel: 11, far: 82547, near: 38492 }, + { tilt: 45, zoomLevel: 12, far: 41273, near: 19246 }, + { tilt: 45, zoomLevel: 13, far: 20637, near: 9623 }, + { tilt: 45, zoomLevel: 14, far: 10318, near: 4812 }, + { tilt: 45, zoomLevel: 15, far: 5159, near: 2406 }, + { tilt: 45, zoomLevel: 16, far: 2580, near: 1203 }, + { tilt: 45, zoomLevel: 17, far: 1290, near: 601 }, + { tilt: 45, zoomLevel: 18, far: 645, near: 301 }, + { tilt: 45, zoomLevel: 19, far: 322, near: 150 }, + { tilt: 45, zoomLevel: 20, far: 161, near: 75 }, + + // Change horizon visibility by changing tilt or offsetting principal point. + { tilt: 60, zoomLevel: 15, far: 8879, near: 2013 }, + { tilt: 60, zoomLevel: 15, far: 293891, near: 2746, ppalPointNDC: [0, -0.9] }, + { tilt: 70, zoomLevel: 15, far: 328139, near: 1641 }, + { tilt: 70, zoomLevel: 15, far: 3856, near: 1020, ppalPointNDC: [0, 0.8] }, + { tilt: 80, zoomLevel: 15, far: 328139, near: 1071 } ]; const sphereZoomTruthTable: ZoomLevelTest[] = [ - { zoomLevel: 1, far: 25028303, near: 19016491 }, - { zoomLevel: 2, far: 14033501, near: 9489079 }, - { zoomLevel: 3, far: 7903190, near: 4733187 }, - { zoomLevel: 4, far: 4369110, near: 2361030 }, - { zoomLevel: 5, far: 1721023, near: 1185829 }, - { zoomLevel: 6, far: 701834, near: 594464 }, - { zoomLevel: 7, far: 329865, near: 297083 }, - { zoomLevel: 8, far: 160586, near: 148186 }, - { zoomLevel: 9, far: 79288, near: 73697 }, - { zoomLevel: 10, far: 39398, near: 36443 }, - { zoomLevel: 11, far: 19635, near: 17813 }, - { zoomLevel: 12, far: 9798, near: 8498 }, - { zoomLevel: 13, far: 4890, near: 3840 }, - { zoomLevel: 14, far: 2439, near: 1511 }, - { zoomLevel: 15, far: 1214, near: 347 }, - { zoomLevel: 16, far: 605, near: 1 }, - { zoomLevel: 17, far: 302, near: 1 }, - { zoomLevel: 18, far: 151, near: 1 }, - { zoomLevel: 19, far: 76, near: 1 }, - { zoomLevel: 20, far: 38, near: 1 }, - { tilt: 45, zoomLevel: 1, far: 24199114, near: 17181678 }, - { tilt: 45, zoomLevel: 2, far: 13812467, near: 7646758 }, - { tilt: 45, zoomLevel: 3, far: 8309093, near: 3024516 }, - { tilt: 45, zoomLevel: 4, far: 5235087, near: 1309563 }, - { tilt: 45, zoomLevel: 5, far: 3408142, near: 602421 }, - { tilt: 45, zoomLevel: 6, far: 2269560, near: 283622 }, - { tilt: 45, zoomLevel: 7, far: 1539268, near: 133965 }, - { tilt: 45, zoomLevel: 8, far: 929666, near: 64077 }, - { tilt: 45, zoomLevel: 9, far: 464827, near: 31590 }, - { tilt: 45, zoomLevel: 10, far: 232410, near: 15466 }, - { tilt: 45, zoomLevel: 11, far: 116201, near: 7434 }, - { tilt: 45, zoomLevel: 12, far: 58097, near: 3425 }, - { tilt: 45, zoomLevel: 13, far: 29045, near: 1423 }, - { tilt: 45, zoomLevel: 14, far: 14519, near: 422 }, - { tilt: 45, zoomLevel: 15, far: 7256, near: 1 }, - { tilt: 45, zoomLevel: 16, far: 3628, near: 1 }, - { tilt: 45, zoomLevel: 17, far: 1814, near: 1 }, - { tilt: 45, zoomLevel: 18, far: 907, near: 1 }, - { tilt: 45, zoomLevel: 19, far: 453, near: 1 }, - { tilt: 45, zoomLevel: 20, far: 227, near: 1 } + { zoomLevel: 1, far: 59464016, near: 53762306 }, + { zoomLevel: 2, far: 32036154, near: 26881153 }, + { zoomLevel: 3, far: 17766076, near: 13440577 }, + { zoomLevel: 4, far: 8418150, near: 6720288 }, + { zoomLevel: 5, far: 3641838, near: 3360144 }, + { zoomLevel: 6, far: 1743526, near: 1680072 }, + { zoomLevel: 7, far: 855246, near: 840036 }, + { zoomLevel: 8, far: 423749, near: 420018 }, + { zoomLevel: 9, far: 210933, near: 210009 }, + { zoomLevel: 10, far: 105235, near: 105005 }, + { zoomLevel: 11, far: 52560, near: 52502 }, + { zoomLevel: 12, far: 26265, near: 26251 }, + { zoomLevel: 13, far: 13129, near: 13126 }, + { zoomLevel: 14, far: 6564, near: 6563 }, + { zoomLevel: 15, far: 3282, near: 3281 }, + { zoomLevel: 16, far: 1641, near: 1641 }, + { zoomLevel: 17, far: 820, near: 820 }, + { zoomLevel: 18, far: 410, near: 410 }, + { zoomLevel: 19, far: 205, near: 205 }, + { zoomLevel: 20, far: 103, near: 103 }, + { tilt: 45, zoomLevel: 1, far: 58067604, near: 51894193 }, + { tilt: 45, zoomLevel: 2, far: 31009971, near: 25013040 }, + { tilt: 45, zoomLevel: 3, far: 17277892, near: 11579312 }, + { tilt: 45, zoomLevel: 4, far: 10131005, near: 5384245 }, + { tilt: 45, zoomLevel: 5, far: 6233888, near: 2584338 }, + { tilt: 45, zoomLevel: 6, far: 3976347, near: 1263063 }, + { tilt: 45, zoomLevel: 7, far: 1500918, near: 623865 }, + { tilt: 45, zoomLevel: 8, far: 696027, near: 309957 }, + { tilt: 45, zoomLevel: 9, far: 338345, near: 154477 }, + { tilt: 45, zoomLevel: 10, far: 167054, near: 77112 }, + { tilt: 45, zoomLevel: 11, far: 83028, near: 38524 }, + { tilt: 45, zoomLevel: 12, far: 41393, near: 19254 }, + { tilt: 45, zoomLevel: 13, far: 20666, near: 9625 }, + { tilt: 45, zoomLevel: 14, far: 10326, near: 4812 }, + { tilt: 45, zoomLevel: 15, far: 5161, near: 2406 }, + { tilt: 45, zoomLevel: 16, far: 2580, near: 1203 }, + { tilt: 45, zoomLevel: 17, far: 1290, near: 601 }, + { tilt: 45, zoomLevel: 18, far: 645, near: 301 }, + { tilt: 45, zoomLevel: 19, far: 322, near: 150 }, + { tilt: 45, zoomLevel: 20, far: 161, near: 75 }, + + // Make horizon visible by offseting principal point. + { zoomLevel: 4, far: 9115538, near: 6018885, ppalPointNDC: [0, -0.9] }, + { zoomLevel: 4, far: 9115538, near: 6018885, ppalPointNDC: [0, 0.9] }, + { zoomLevel: 4, far: 9992660, near: 6720288, ppalPointNDC: [-0.9, 0] }, + { zoomLevel: 4, far: 9992660, near: 6720288, ppalPointNDC: [0.9, 0] } ]; describe("ClipPlanesEvaluator", function () { @@ -232,7 +252,7 @@ describe("ClipPlanesEvaluator", function () { farMaxRatio ); // Tilt camera to force a large far distance. - const tiltDeg = 45; + const tiltDeg = 60; const camera = setupPerspectiveCamera(projection, undefined, distance, tiltDeg); const viewRange = evaluator.evaluateClipPlanes(camera, projection); const eps = 1e-6; @@ -241,13 +261,31 @@ describe("ClipPlanesEvaluator", function () { }); describe("evaluateClipPlanes returns correct values for each zoom & tilt", function () { zoomTruthTable.forEach((test: ZoomLevelTest) => { - it(`zoom level ${test.zoomLevel}, tilt ${test.tilt ?? 0}`, function () { - const evaluator = new TiltViewClipPlanesEvaluator(); + it(`zoom level ${test.zoomLevel}, tilt ${test.tilt ?? 0}, ppal point ${ + test.ppalPointNDC ?? [0, 0] + }`, function () { + // Relax constraints to see the effects of tilt and principal point. + const minElevation = 0; + const maxElevation = 0; + const minNear = 1; + const nearFarMarginRatio = 0; + const farMaxRatio = 100; + const evaluator = new TiltViewClipPlanesEvaluator( + minElevation, + maxElevation, + minNear, + nearFarMarginRatio, + farMaxRatio + ); + const ppalPointNDC = test.ppalPointNDC + ? new THREE.Vector2(test.ppalPointNDC[0], test.ppalPointNDC[1]) + : undefined; const camera = setupPerspectiveCamera( projection, test.zoomLevel, undefined, - test.tilt + test.tilt, + ppalPointNDC ); const viewRange = evaluator.evaluateClipPlanes(camera, projection); expect(Math.round(viewRange.far)).eq(test.far); diff --git a/@here/harp-mapview/test/SphereHorizonTest.ts b/@here/harp-mapview/test/SphereHorizonTest.ts index 53895a71f9..9b782a8883 100644 --- a/@here/harp-mapview/test/SphereHorizonTest.ts +++ b/@here/harp-mapview/test/SphereHorizonTest.ts @@ -64,12 +64,13 @@ describe("SphereHorizon", function () { return angle >= 0 ? angle / (2 * Math.PI) : 1 + angle / (2 * Math.PI); } - before(function () { - focalLength = CameraUtils.computeFocalLength(MathUtils.degToRad(vFov), canvasHeight); - }); - beforeEach(function () { camera = new PerspectiveCamera(vFov); + focalLength = CameraUtils.computeFocalLength( + camera, + MathUtils.degToRad(vFov), + canvasHeight + ); setCamera(3); }); diff --git a/test/rendering/ClipPlanesRenderingTest.ts b/test/rendering/ClipPlanesRenderingTest.ts new file mode 100644 index 0000000000..2350b6d71d --- /dev/null +++ b/test/rendering/ClipPlanesRenderingTest.ts @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2021 HERE Europe B.V. + * Licensed under Apache 2.0, see full license in LICENSE + * SPDX-License-Identifier: Apache-2.0 + */ +import { FeatureCollection, StyleSet } from "@here/harp-datasource-protocol"; +import { mercatorProjection, Projection, sphereProjection } from "@here/harp-geoutils"; +import { CameraUtils } from "@here/harp-mapview"; +import * as THREE from "three"; + +import { GeoJsonTest } from "./utils/GeoJsonTest"; +import { ThemeBuilder } from "./utils/ThemeBuilder"; + +const style: StyleSet = [ + { + when: "$geometryType == 'polygon'", + technique: "fill", + attr: { + color: ["get", "color"], + opacity: 1.0, + minZoomLevel: ["get", "minZoom"], + maxZoomLevel: ["get", "maxZoom"] + } + } +]; +const theme = { clearColor: "black", lights: ThemeBuilder.lights, styles: { geojson: style } }; +const seaworld: FeatureCollection = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: { color: "#436981" }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-180, -90], + [180, -90], + [180, 90], + [-180, 90], + [-180, -90] + ] + ] + } + }, + // Crosshair at camera target [0,0] for high zoom levels. + { + type: "Feature", + properties: { color: "red", minZoom: 6 }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-0.002, -0.02], + [0.002, -0.02], + [0.002, 0.02], + [-0.002, 0.02], + [-0.002, -0.02] + ] + ] + } + }, + { + type: "Feature", + properties: { color: "red", minZoom: 6 }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-0.02, -0.002], + [0.02, -0.002], + [0.02, 0.002], + [-0.02, 0.002], + [-0.02, -0.002] + ] + ] + } + }, + // Crosshair at camera target [0,0] for low zoom levels. + { + type: "Feature", + properties: { color: "red", maxZoom: 6 }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-0.1, -1], + [0.1, -1], + [0.1, 1], + [-0.1, 1], + [-0.1, -1] + ] + ] + } + }, + { + type: "Feature", + properties: { color: "red", maxZoom: 6 }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-1, -0.1], + [1, -0.1], + [1, 0.1], + [-1, 0.1], + [-1, -0.1] + ] + ] + } + } + ] +}; + +describe("ClipPlanes rendering test", function () { + interface ClipPlanesTest { + projection: Projection; + zoomLevel: number; + tilt?: number; + principalPointNDC?: [number, number]; + } + + this.timeout(5000); + + let geoJsonTest: GeoJsonTest; + + beforeEach(function () { + geoJsonTest = new GeoJsonTest(); + }); + + afterEach(() => geoJsonTest.dispose()); + + const tests: ClipPlanesTest[] = [ + { + projection: mercatorProjection, + zoomLevel: 11, + tilt: 65 + }, + { + projection: mercatorProjection, + zoomLevel: 11, + tilt: 65, + principalPointNDC: [0, -0.5] + }, + { + projection: mercatorProjection, + zoomLevel: 11, + tilt: 70 + }, + { + projection: mercatorProjection, + zoomLevel: 11, + tilt: 70, + principalPointNDC: [0, 0.5] + }, + { + projection: sphereProjection, + zoomLevel: 11, + tilt: 65 + }, + { + projection: sphereProjection, + zoomLevel: 11, + tilt: 65, + principalPointNDC: [0, -0.5] + }, + { + projection: sphereProjection, + zoomLevel: 11, + tilt: 70 + }, + { + projection: sphereProjection, + zoomLevel: 11, + tilt: 70, + principalPointNDC: [0, 0.5] + }, + { + projection: sphereProjection, + zoomLevel: 4, + tilt: 10 + }, + { + projection: sphereProjection, + zoomLevel: 4, + tilt: 10, + principalPointNDC: [0, -0.9] + }, + { + projection: sphereProjection, + zoomLevel: 4, + principalPointNDC: [-0.7, 0] + }, + { + projection: sphereProjection, + zoomLevel: 4, + principalPointNDC: [0.7, 0] + }, + { + projection: sphereProjection, + zoomLevel: 3, + principalPointNDC: [0, 0.9] + } + ]; + for (const test of tests) { + const projection = test.projection; + const zoomLevel = test.zoomLevel; + const tilt = test.tilt ?? 0; + const principalPoint = test.principalPointNDC ?? [0, 0]; + const projName = projection === mercatorProjection ? "mercator" : "sphere"; + const testName = `${projName}, zl${zoomLevel}, tilt ${tilt}, ppal point ${principalPoint}`; + // For image name, replace test name spaces and commas with dashes and make lower case. + const testImageName = "clip-planes-" + testName.replace(/[,\s]+/g, "-").toLowerCase(); + + it(`${testName}`, async function () { + await geoJsonTest.run({ + mochaTest: this, + testImageName, + theme, + geoJson: seaworld, + lookAt: { + zoomLevel, + target: [0, 0], + tilt + }, + projection, + beforeFinishCallback: mapView => { + CameraUtils.setPrincipalPoint( + mapView.camera, + new THREE.Vector2().fromArray(principalPoint) + ); + } + }); + }); + } +});