Issue
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
- the movement is too coarse when close to the surface;
- the viewer unexpectedly "punches through" the material and we see stuff on the other side;
- 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 theParallelCamera
.
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.
Answered By - trashgod
Answer Checked By - Candace Johnson (JavaFixing Volunteer)