Search code examples
javajavafx3dzooming

Effective way to zoom the camera very close to 3D shapes?


I think I'm going to have to let my code speak for itself here. I am creating a map to plot GPS coordinates. I've decided to plot these onto a 3D globe. I've decided to try javafx and I'm using javafx-sdk-18.0.2.

Something I have not been able to crack is extreme zooming of the PerspectiveCamera. I'd like to zoom all the way from space, down to the 10s-of-meters level, to display recorded GPS data trails.

I have a simplified example coded to exhibit my problem. I've decorated the globe with just a few points to give you a general reference. The user can rotate to a location on the globe using the four arrow keys, and I'm allowing zooming in and out using the + and - keys. I've tried various methods to zoom: measuring the distance between the camera and surface, translating the camera eye; adjusting the "scale" factor; and adjusting the "field of view" angle. None of the results are working adequately and I suspect that I'm just not using this API correctly. The problems I have are

  1. the movement is too coarse when close to the surface;
  2. the viewer unexpectedly "punches through" the material and we see stuff on the other side;
  3. and with very small Camera.nearClip values, all the shapes become corrupted with pieces missing.

Could somebody please propose how zooming to fine detail could be best achieved?

package ui.javafx;

import javafx.application.Application;
import javafx.geometry.Point3D;
import javafx.scene.*;
import javafx.scene.control.Label;
import javafx.scene.transform.*;
import javafx.scene.input.*;
import javafx.scene.shape.*;
import javafx.scene.paint.*;
import javafx.stage.*;

/** Simplified working javafx example for Stackoverflow question */
public class OthographicGlobeMapStackOverflow extends Application {

/**
 * An oblate spheroid coordinate system approximating the layout of the Earth.
 */
class Earth {
    /*
     * Earth size constants from WGS-84 as expressed on
     * https://en.wikipedia.org/wiki/Earth_ellipsoid#Historical_Earth_ellipsoids
     */
    final static double RADIUS_EQUITORIAL_METERS = 6378137d;
    final static double RADIUS_POLAR_METERS = 6356752d;

    /**
     * Size of the scaled globe in pixels. Radius in X coordinate.
     */
    final static double globeRadiusX = 300d;

    /**
     * Size of the globe in pixels. Radius in Y coordinate.
     */
    final static double globeRadiusY = RADIUS_POLAR_METERS / RADIUS_EQUITORIAL_METERS * globeRadiusX;

    /**
     * Produce a Point3D with the location in the xyz universe, corresponding with
     * the location on the globe with the provided coordinates in degrees and
     * meters.
     * Algorithm adapted from https://stackoverflow.com/a/5983282/399723
     * 
     * @returns a Point3D at the specified location in relation to the globe.
     * @param degreesLatitude the Latitude in degrees.
     * @param degreesLongitude the longitude in degrees.
     * @param metersAltitude the altitude from AMSL in metres.
     */
    public static Point3D getWithDegrees( double degreesLatitude, double degreesLongitude, float metersAltitude ) {

        double Re = globeRadiusX;
        double Rp = globeRadiusY;

        // the algorithm produced a globe with longitude -90 facing us
        degreesLongitude = ( degreesLongitude - 90d ) % 360d;
        
        double lat = Math.toRadians( degreesLatitude );
        double lon = Math.toRadians( degreesLongitude );

        double coslat = Math.cos( lat );
        double sinlat = Math.sin( lat );
        double coslon = Math.cos( lon );
        double sinlon = Math.sin( lon );

        double term1 = Math.sqrt( Re * Re * coslat * coslat + Rp * Rp * sinlat * sinlat );
        double term2 = metersAltitude * coslat + ( Re * Re * coslat ) / term1;
        
        double x = coslon * term2;
        double y = sinlon * term2;
        double z = metersAltitude * sinlat + ( Rp * Rp * sinlat ) / term1;

        // the x,y,z directions were not congruent with the JavaFX layout axes
        return new Point3D( x, -z, y );

    }
    public static Point3D getNorthPole() {
        return getWithDegrees( 90, 0, 0 );
    }
}

/**
 * Angle of globe view, in longitude degrees which effects a rotation of the X
 * axis around the Y axis.
 */
private double spinAngle = 0d;

/**
 * Angle of globe view, in latitude degrees
 */
private double tiltAngle = 0d;

@Override
public void start( Stage primaryStage ) {

    // Universe stays fixed. Contains lighting, camera and the axis of the "tilt" function.
    Group universe = new Group();
    addSunlight( universe );
    
    // Globe is able to rotate in its own axis. Child nodes that decorate the globe remain in position.
    Group globe = new Group();
    universe.getChildren().add( globe );

    // add a nice looking surface to the globe
    drawGlobe( globe );

    // paint few dotted lines on the globe surface for orientation
    drawLatitude( globe, 60 );
    drawLatitude( globe, 30 );
    drawLatitude( globe, 0 );
    drawLatitude( globe, -30 );
    drawLatitude( globe, -60 );
    drawLongitude( globe, 0 ); // prime meridian great circle

    // decorate the globe with a few positional balls
    plotGoldBall( globe, 48.85829501324163, 2.294502751853257, "Tour Eiffel" );
    plotGoldBall( globe, 40.68937198546735, -74.04451898086933, "Statue of Liberty" );
    plotGoldBall( globe, -22.952395566439044, -43.21046847195321, "Cristo Redentor" );
    plotGoldBall( globe, 35.65873215542844, 139.74547513704502, "東京タワー" ); // Tokyo Tower
    plotGoldBall( globe, 29.97918805575227, 31.134206635494273, "هرم خوفو" ); // pyramid of Cheops
    plotGoldBall( globe, -27.116667, -109.366667, "🗿🗿🗿🗿🗿🗿🗿" ); // Parque nacional Rapa Nui, Easter Island
    plotGoldBall( globe, -33.85617854877629, 151.21533961498702, "Sydney Opera House" );

    // translate the globe away from the origin in the corner
    globe.setTranslateX( Earth.globeRadiusX * 1d );
    globe.setTranslateY( Earth.globeRadiusX * 1d );
    globe.setTranslateZ( 0d );
    
    // Establish spinning axis for the globe
    Rotate globeSpin = new Rotate( spinAngle, Earth.getNorthPole() );
    globe.getTransforms().addAll( globeSpin );

    // Establish tilting on the universe (or camera view which is how user perceives it)
    Rotate globeTilt = new Rotate( tiltAngle, Rotate.X_AXIS );
    globeTilt.setPivotX( Earth.globeRadiusX * 1d );
    globeTilt.setPivotY( Earth.globeRadiusX * 1d );
    globeTilt.setPivotZ( 0 );
    universe.getTransforms().add( globeTilt );
    
    // establish the size of the window and display it
    Scene scene = new Scene( universe, Earth.globeRadiusX * 2, Earth.globeRadiusX * 2, true );
    PerspectiveCamera eye = new PerspectiveCamera();
    eye.setNearClip( 0.001d );
    scene.setCamera( eye );
    primaryStage.setScene( scene );

    // add point-to-identify mouse handler
    primaryStage.addEventHandler( MouseEvent.MOUSE_PRESSED, event -> {
        PickResult clicked = event.getPickResult();
        System.out.println( "Clicked on: " + clicked.getIntersectedNode() );
    } );
    
    // add ← ↑ → ↓ and +/- controls
    primaryStage.addEventHandler( KeyEvent.KEY_PRESSED, event -> {

        if ( event.getCode().equals( KeyCode.UP ) ) {
            globeTilt.setAngle( --tiltAngle );
        }
        if ( event.getCode().equals( KeyCode.DOWN ) ) {
            globeTilt.setAngle( ++tiltAngle );
        }
        if ( event.getCode().equals( KeyCode.LEFT ) ) {
            globeSpin.setAngle( --spinAngle );
        }
        if ( event.getCode().equals( KeyCode.RIGHT ) ) {
            globeSpin.setAngle( ++spinAngle );
        }
        if ( event.getCode().equals( KeyCode.EQUALS ) ) {
            zoomIn( eye );
        }
        if ( event.getCode().equals( KeyCode.MINUS ) ) {
            zoomOut( eye );
        }
    } );
    primaryStage.show();
}

/**
 * Draw a pretty blue spheroid. This is a visual backdrop to the positional elements placed on the globe.
 * It also functions as a visual solid, hiding elements that are "behind".
 * */
private void drawGlobe( Group globe ) {
    Sphere earth = new Sphere( Earth.globeRadiusX );
    earth.setScaleY( Earth.globeRadiusY / Earth.globeRadiusX ); // squash into oblate a little
    earth.setId( "Earth" );
    PhongMaterial surface = new PhongMaterial();
    surface.setDiffuseColor( Color.AZURE.deriveColor( 0.0, 1.0, 1.0, 1.0 ) );
    earth.setMaterial( surface );
    globe.getChildren().add( earth );
}

private void addSunlight( Group universe ) {
    PointLight sol = new PointLight( Color.WHITE.deriveColor( 0.0, 0.5, 0.5, 0.5 ) );
    sol.setTranslateZ( -3000 );
    sol.setTranslateY( -1000 );
    sol.setTranslateX( -1000 );
    universe.getChildren().add( sol );
    AmbientLight starlight = new AmbientLight( Color.ANTIQUEWHITE.deriveColor( 0.0, 0.5, 0.5, 0.5 ) );
    universe.getChildren().add( starlight );
}

/**
 * Place a gold-looking ball marker on the surface of the globe
 * @param labelText
 */
private void plotGoldBall( Group globe, double latitude, double longitude, String labelText ) {
    Sphere marker = plotBall( globe, latitude, longitude, labelText, 10d, Color.BLANCHEDALMOND );
    Label label = new Label();
    label.setText( labelText );
    if ( longitude % 180d > 0 ) {
        label.setTranslateX( marker.getTranslateX() + 50 );
    }
    else {
        label.setTranslateX( marker.getTranslateX() - ( label.getWidth() + 50 ) );
    }
    label.setTranslateY( marker.getTranslateY() );
    label.setTranslateZ( marker.getTranslateZ() );
    globe.getChildren().add( label );
}

/**
 * Place a series of small black dots to denote circle of latitude
 * @param lat the latitude in degrees.
 * */
private void drawLatitude( Group globe, double lat ) {
    int step = 1;
    if ( Math.abs( lat ) > 45 )
        step = 2;
    for (double deg = 0; deg < 360; deg += step) {
        plotBlackDot( globe, lat, deg );
    }
}

/**
 * Place a series of small black dots to denote a great circle of longitude
 * @param the longitude to start the great circle.
 * */
private void drawLongitude( Group globe, double lon ) {
    for (double deg = 0; deg < 360; deg++) {
        plotBlackDot( globe, deg, lon );
    }
}

private void plotBlackDot( Group globe, double lat, double lon ) {
    plotBall( globe, lat, lon, null, 1d, Color.DARKSLATEBLUE );
}

private Sphere plotBall( Group globe, double latitude, double longitude, String label, double radius, Color color ) {
    Point3D location = Earth.getWithDegrees( latitude, longitude, 0 );
    Sphere mapPoint = new Sphere( radius );
    mapPoint.setId( label );
    mapPoint.setTranslateX( location.getX() );
    mapPoint.setTranslateY( location.getY() );
    mapPoint.setTranslateZ( location.getZ() );
    mapPoint.setMaterial( new PhongMaterial( color ) );
    globe.getChildren().add( mapPoint );
    return mapPoint;
}

/* WTF */
private void zoomIn( PerspectiveCamera eye ) {
    eye.setFieldOfView( eye.getFieldOfView() * 1.1d );
    eye.setScaleZ( eye.getScaleZ() / 1.1d );
}

/* WTF */
private void zoomOut( PerspectiveCamera eye ) {
    eye.setFieldOfView( eye.getFieldOfView() / 1.1d );
    eye.setScaleZ( eye.getScaleZ() * 1.1d );
}
}

New information

My original attempt was to translate the camera in Z-axis. But, how to measure the distance the camera is from a given point? The globe (Group) is in its own coordinate system and has undergone rotate transforms. I couldn't make sense of the Z measurements I took.

My conclusion was that I should stop trying to find out where things are, and instead research the capabilities of Camera. Which led me to the FOV and scaling.

class ShowJavaSyntaxHighlightingForCodeFragment {

/* WTF */
private void zoomIn( PerspectiveCamera eye ) {
    System.out.println( "\nZooming in." );
    
    // distance remaining between eye and nearest globe surface point
    Point3D zoomPoint = Earth.getWithDegrees( tiltAngle, -1d * spinAngle, 0 );
    System.out.println( "Surface point: " + zoomPoint.getZ() );
    System.out.println( "View point: " + eye.getTranslateZ() );
    double distance = Math.abs( eye.getTranslateZ() - zoomPoint.getZ() );
    System.out.println( "Zoom distance: " + distance );
    // close the remaining distance by half
    eye.setTranslateZ( ( eye.getTranslateZ() + ( distance / 2d ) ) );
    
    // report the new distance
    distance = Math.abs( eye.getTranslateZ() - zoomPoint.getZ() );
    System.out.println( "New view point: " + eye.getTranslateZ() );
    System.out.println( "New zoom distance: " + distance );
}

/* WTF */
private void zoomOut( PerspectiveCamera eye ) {
    System.out.println( "\nZooming out." );
    
    // distance remaining between eye and nearest globe surface point
    Point3D zoomPoint = Earth.getWithDegrees( tiltAngle, -1d * spinAngle, 0 );
    System.out.println( "Surface point: " + zoomPoint.getZ() );
    System.out.println( "View point: " + eye.getTranslateZ() );
    double distance = Math.abs( eye.getTranslateZ() - zoomPoint.getZ() );
    System.out.println( "Zoom distance: " + distance );
    // attempt to double the closing distance
    eye.setTranslateZ( ( eye.getTranslateZ() + distance ) ) );
    
    // report the new distance
    distance = Math.abs( eye.getTranslateZ() - zoomPoint.getZ() );
    System.out.println( "New view point: " + eye.getTranslateZ() );
    System.out.println( "New zoom distance: " + distance );
}}

From that logic, I get this output.

Zooming in.
Surface point: -300.0
View point: 0.0 // I wasn't expecting 0 in Z-axis here
Zoom distance: 300.0
New view point: 150.0 // OK, plausible
New zoom distance: 450.0 // Nonsense. I was expecting a smaller value.

Zooming in.
Surface point: -300.0
View point: 150.0
Zoom distance: 450.0
New view point: 375.0
New zoom distance: 675.0 // Nonsense. The image is bigger, but the distance is greater. 

Zooming in.
Surface point: -300.0
View point: 375.0
Zoom distance: 675.0
New view point: 712.5 // I have no idea what is happening, but the view is definitely zoomed
New zoom distance: 1012.5 

Zooming out.
Surface point: -300.0
View point: 712.5
Zoom distance: 1012.5
New view point: -1312.5 // This is nonsense again, and the view is far more zoomed out than I intended.
New zoom distance: 1012.5

Solution

  • One approach is to dolly along the Z axis using mouse scrolling, as shown here. The image is zoomed to Easter Island.

    scene.setOnScroll((final ScrollEvent e) -> {
        eye.setTranslateZ(eye.getTranslateZ() + e.getDeltaY());
    });
    

    …my camera can translate 817 pixels before it intersects with the equator.

    This value likely arises in connection with the default PerspectiveCamera. In particular,

    the Z value of the eye position is adjusted in Z such that the projection matrix generated using the specified fieldOfView will produce units at Z = 0 (the projection plane), in device-independent pixels, matches that of the ParallelCamera.

    Add the following to the scroll handler above to see the value appear as the eye intersects the Earth:

    System.out.println(eye.getTranslateZ());
    

    See also JavaFX: Working with JavaFX Graphics: §3 Camera.

    Easter Island