3

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
Douglas Held
  • 1,452
  • 11
  • 25

2 Answers2

3

The effect you've created by combining changing the FOV with scaling the camera's Z dimension creates a cool trippy effect!

But it's the wrong way to do zoom.

Question: Why not translate the camera (eye) when zooming? That's what I do with my FX 3D scenes and do not experience any corruption. (Win10/NVidia/OpenJFX18)

Having done such things here are some tips to your questions:

  1. the movement is too coarse when close to the surface;

Instead of a 10% change use a scalar formula based on radius from center of globe. Using the 10% change is cheap and easy but it does not scale properly at close or far distances. The scalar should simply use the radius as a way to reduce the rate of change for a zoom in action. This will make it more fine grain the closer you walk in to the globe. Pro Tip: Test for Control down for an additional scalar to make it even more fine (crawl) or Shift Down for an additional scalar to make it much faster (run).

  1. the viewer unexpectedly "punches through" the material and we see stuff on the other side;

What did you expect if you don't bound your zoom action? Just add logic that ignores the zoom when the distance between the camera and the center is below a certain amount. (another reason why you should be translating the camera)

  1. and with very small Camera.nearClip values, all the shapes become corrupted with pieces missing.

This shouldn't happen if you are translating properly, aside from the odd Z-fighting that you may see if you are messing around with the textures heavily.

Birdasaur
  • 736
  • 7
  • 10
  • Q: What did you expect if you don't bound your zoom action? A: I expected a vanishingly small field of view, approaching 0 degrees. I didn't expect elements of the foreground to disappear. – Douglas Held Oct 18 '22 at 17:10
  • Camera translation is the first thing I tried. It seemed to me, the globe and the camera weren't sharing a coordinate system, as the camera was never where I expected it to be after applying. I will dig up my camera translation attempt and add that. – Douglas Held Oct 18 '22 at 17:13
  • 1
    I'm thinking something simple. Example (altered) on how I use the mouse wheel to zoom: subScene.setOnScroll((ScrollEvent event) -> { double modifier = 50.0; double modifierFactor = 0.1; if (event.isControlDown()) { modifier = 1; } if (event.isShiftDown()) { modifier = 100.0; } double z = camera.getTranslateZ(); double newZ = z + event.getDeltaY() * modifierFactor * modifier; camera.setTranslateZ(newZ); }); – Birdasaur Oct 19 '22 at 14:36
  • 2
    Here is a full example which uses camera translation. https://github.com/FXyz/FXyz/blob/master/FXyz-Samples/src/main/java/org/fxyz3d/samples/utilities/FloatingLabels.java Caveat: I am the repo's owner and author of that example. Not trying to advertise that library. – Birdasaur Oct 19 '22 at 14:40
  • @Birdsaur THANK YOU very much. I will have a deep look and see what is going on. – Douglas Held Oct 19 '22 at 16:44
2

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

trashgod
  • 203,806
  • 29
  • 246
  • 1,045
  • That's very simple and helpful. Now I found, for whatever reason, my camera can translate 817 pixels before it intersects with the equator. Tomorrow, I can see if there is an obvious reason for that value. – Douglas Held Oct 19 '22 at 18:18
  • I've elaborated above. – trashgod Oct 20 '22 at 18:30
  • Thank you. Of course I read the JavaDoc and the tutorial, found them seriously lacking in exposition of the features. – Douglas Held Oct 21 '22 at 22:10
  • 1
    Glad it helped; permit me to second the reference to [José Pereda's](https://github.com/jperedadnr) work in this area. – trashgod Oct 21 '22 at 22:45
  • 1
    It's obvious there are two very good answers here. I awarded the checkmark here because your one-liner of code is enough for me to unblock myself and get this API working for myself further. – Douglas Held Oct 22 '22 at 13:53