プロジェクト

全般

プロフィール

JavaFX 3Dを理解する

JavaFX 3Dは、サーフェイスモデルの3次元グラフィックスで、フォンシェーディングによる陰影を付けた描画を行います。2Dと違って、3D表示のために次の要素をプログラミングする必要があります。

  • モデル(Shape3D)
  • 材質(Material)
  • 光源(Light)
  • カメラ(Camera)

モデル(Shape3D)

モデルは、事前定義されているBox、Cylinder、SphereおよびTriangleMesh(三角ポリゴン)を使って任意のサーフェイスを定義するMeshViewを使います。いずれのクラスもjavafx.scene.shape.Shape3Dのサブクラスです(TriangleMeshは別)。

モデルは、JavaFXのシーンにシーングラフの一部として追加します。

材質(Material)

材質は、各種光源に対する反射の色、およびテクスチャ画像を設定します。

  • 拡散光に対する反射色(DiffuseColor)
  • 鏡面光に対する反射色(SpecularColor)
  • 拡散光に対するサーフェイスのテクスチャ画像(DiffuseMap)
  • 鏡面光に対するサーフェイスのテクスチャ画像(SpecularMap)
  • SpecularPower

光源

光源は、環境光(AmbientLight)と点光源(PointLight)があります。

カメラ

カメラは、並行投影(ParallelCamera)と一点透視投影(PerspectiveCamera)があります。

ケーススタディ1 球体を表示する

空のシーングラフの表示

まず、空のシーングラフでウィンドウだけ表示する雛形のソースコードです。

source:learn/java/javafx/Hello3d/Hello3d.java@3cf7d266

  • Sceneを生成するときに背景色を指定しています。
    final Scene scene = new Scene(root, 800, 600, Color.BLACK);
    

コンパイルします。

 Hello3d$ javac Hello3d.java

実行します。

 Hello3d$ java Hello3d

すると次の画面が表示されます。

casestudy1_01.png

球体のモデルとカメラ

3次元表示に最低限必要なのが、モデルとカメラです。モデルだけ作成しても描画を確認できませんでした。

source:learn/java/javafx/Hello3d/Hello3d.java@29192059

  • 球体(Sphere)を半径100で生成してみました。中心は(0, 0, 0)となります。
    final Sphere earth = new Sphere(100);
    
  • PerspectiveCameraを生成するときにfixedEyeAtZeroプロパティをtrueに設定しています。3D空間においてカメラ位置を制御するときに必要な設定です。デフォルトコンストラクタで生成するとfixedEyeAtZeroプロパティはfalseに設定され、2Dシーンのレンダリング用(カメラ位置は制御できない)となります。
    final PerspectiveCamera camera = new PerspectiveCamera(true);
    
  • PerspectiveCameraの視野角はデフォルトは30度ですが、人間の視野角は景色を見るときは45度くらい1と思い、視野角を45度に設定しています。
    camera.setFieldOfView(45.0);
    
  • PerspectiveCameraの視点位置はfixedEyeAtZeroプロパティをtrueに設定したときは、(0, 0, 0)となります。これでは球体のど真ん中になってしまうので、Z軸方向に球体の半径より大きい距離だけ引いて位置するようにしています。
    camera.getTransforms().addAll(
        new Translate(0, 0, -180)
    );
    

コンパイルして実行すると次の画面が表示されます。

casestudy1_02.png

1 人間の視野ではっきり見える範囲は35mmカメラで焦点距離50mm程度といわれているそうです。これは、おおよそ27度(垂直方向)~40度(水平方向)になります。JavaFXのPerspectiveCameraのFieldOfViewプロパティが30度というのは垂直方向では妥当な値と思われます。ただし、臨場感としてはもう少し角度を大きくとった方がよいかもしれません。NHK技研のレポート(NHK技研 R&D/No.130/2011.11 p.4)によれば、臨場感を被験者の重心動揺で評価したところ視野角20度から効果が現れ80~100度で飽和したとのことです。スーパーハイビジョンではこれをもとに視野角100度を実現する映像システムとして設計されているようです。

球体の材質の設定(色)

拡散光の反射

まずは基本となる拡散光の反射を設定します。

source:learn/java/javafx/Hello3d/Hello3d.java@a87c4319

  • PhonMaterialクラスを生成し、拡散光の反射色(diffuseColor)を指定します。
    final PhongMaterial material = new PhongMaterial();
    material.setDiffuseColor(Color.DODGERBLUE);
    earth.setMaterial(material);
    
  • カメラ位置を少し引きました(translateZを-180から-190に変更)。

コンパイルをします。ソースコードの文字コードをUTF-8にしているのでWindows環境では-encodingオプションが必要です。

Hello3d$ javac -encoding UTF-8 Hello3d.java

実行すると次の画面が表示されます。

casestudy1_03.png

鏡面光の反射

次は光沢感が出る鏡面光の反射色を設定します。

source:learn/java/javafx/Hello3d/Hello3d.java@efda9938

  • 鏡面光の反射色(specularColor)を指定します。
    material.setSpecularColor(Color.DEEPSKYBLUE);
    

コンパイルし実行すると次の画面が表示されます。

casestudy1_04.png

デフォルトの光源の位置がど真ん中になっているようです。見栄えがいまいちですね。

光源の設定

点光源(PointLight)

点光源を設定します。

source:learn/java/javafx/Hello3d/Hello3d.java@38b62b0a

  • 点光源(PointLight)を白色で定義します。光源の位置は右側手前から球体に当たる位置にしています。
    final PointLight pointLight = new PointLight(Color.WHITE);
    pointLight.setTranslateX(240);
    pointLight.setTranslateY(0);
    pointLight.setTranslateZ(-250);
    root.getChildren().add(pointLight);
    

コンパイルし実行すると次の画面が表示されます。

casestudy1_05.png

点光源なので、光と影が明確につきます。

環境光(AmbientLight)

環境光を設定します。

source:learn/java/javafx/Hello3d/Hello3d.java@685d257b

  • 環境光(AmbientLight)を灰色かつ透過度50%で定義します。
    final AmbientLight ambientLight = new AmbientLight(Color.rgb(80, 80, 80, 0.5));
    root.getChildren().add(ambientLight);
    

コンパイルし実行すると次の画面が表示されます。

casestudy1_06.png

前述の点光源を設定したときの画面とよく見比べると、影になっている部分の明るさが違うことが分かります。

球体の材質の設定(テクスチャマップ)

球体の材質を、色ではなく画像を貼ったもの、いわゆるテクスチャマップとします。

世界地図(緯度経度直交座標系、メルカトルなど)の画像

今回は次のサイトからフリーのコンテンツを入手して使用します。
http://www.free-world-maps.com
Webサイト上での利用はライセンスを見ると、入手元サイトへのリンクがあればよいとあります。
このサイトから次の画像を入手して使用します。
physical-free-world-map-b1

JavaFX 8時点での球体(Sphere)へのテクスチャマップのuvマッピングは変則的な投影となっており、緯度経度直交座標系(正距円筒投影)の画像をテクスチャに使用すると、縦に潰れたような表示となっています。本記事の表示は縦に潰れた状況になっています。地球をそれなりに表示するには、入手した画像をGISソフトウェア等を使って正積円筒投影した画像に変換してから使用することで回避します。この経緯をはてな日記に記載しています。
http://d.hatena.ne.jp/torutk/20161031/p1

正積円筒投影画像(Blue Marble)

上のfree-world-mapsの画像はGISソフトウェアで投影させるのが歪みが多く難しかったので、別途Blue Marbleサイトの画像を入手し正積円筒投影をしました。
http://visibleearth.nasa.gov/view.php?id=57752

以下が正積円筒投影画像です。

land_shallow_topo_2048_CyrindricalEqualArea.png

以降の本記事で使用しているテクスチャ画像をこのBlue Marble正積円筒投影画像に差し替えると、見た目の違和感が軽減されます。

テクスチャマップの設定(その1)

ダウンロードした世界地図画像をソースファイルと同じ場所に保存します。

  • 画像ファイルはリソースとして指定しImageインスタンスを生成します。
    private final Image earthImage = new Image(getClass().getResourceAsStream("physical-free-world-map-b1.jpg"));
    
  • PhongMaterialの反射色設定は削除しテクスチャマップの設定をします。
    // 材質の定義
    final PhongMaterial material = new PhongMaterial();
    material.setDiffuseMap(earthImage);
    earth.setMaterial(material);
    

コンパイルし実行すると次の画面が表示されます。

casestudy1_07.png

あれ、アフリカ大陸が地球いっぱいに表示されてしまいました。大きさが思いっきりずれています。

貼り付けた画像ファイルのピクセルは横x高さが1000x500であり、球体は半径が100です。
おそらくこの違いが原因ではないかと思われます。
Sphereの半径を10にして画像を貼ってみたらそれなりに表示されたので、別な原因です。
  • PerspectiveCameraのプロパティを調べると、nearClipが0.1、farClipが100に設定されていました。Sphereの半径を100にしたので、まずい値かと思います。
  • テクスチャの画像がjpegだと少しぼやける表示になります。pngにしたらくっきりした表示になります。

テクスチャマップの設定(その2)

PerspectiveCameraには、nearClipとfarClipがあり、デフォルトではfarClipが100に設定されています。そこで、カメラから100より遠い場所にあるオブジェクトはクリップされてしまい表示されません。今回、球体の半径を100に設定しているので、カメラとfarClipのデフォルト100との間に表示対象オブジェクトである球体(直径200)を収め切れません。

そこで、farClipの値をデフォルトの100から1000に変更してみます。

  • PerspectiveCameraのfarClipプロパティを変更します。
    camera.setFarClip(1000);

合わせて、カメラの位置を球体全体が表示されるように少し引いています。

コンパイルし実行すると次の画面が表示されます。

casestudy1_08.png

カメラ

カメラの移動については、見直しが必要なことが判明。追って修正していきます。

カメラの移動(その1)

PerspectiveCameraを移動させることによって表示を変えてみます。
ちょうど衛星から地球を見るように、衛星を地球軌道上に移動させることで地球を回ってみたいと思います。

movingplan.png

JavaFXでは、コンテンツを動かすためにアニメーション(TransisionおよびTimeline)機能が用意されています。
今回は、Timelineを使って実装してみました。

Timelineでは、KeyFrameと呼ぶアニメーションの時間進行の要所要所でのモデルの状態(プロパティ)を定義します。
そして、あるKeyFrameと次のKeyFrameの間でモデルの状態(プロパティ)に違いがあればそれをInterpolatorと呼ぶ機能で補間します。例えば、立方体(Box)のX座標(translateXProperty)が開始0秒時点のKeyFrameで100に設定し、開始3秒時点のKeyFrameで200に設定しておくと、アニメーション時に3秒間かけてX座標が100から200に移動する動きとなります。

カメラの位置と向きは、X,Y,Z座標の並行移動と、X軸,Y軸,Z軸に沿っての回転量で指定します。とりあえずアプリケーションクラスのフィールドにこのプロパティを定義します。

    private final Rotate cameraRotateX = new Rotate(0, Rotate.X_AXIS);
    private final Rotate cameraRotateY = new Rotate(0, Rotate.Y_AXIS);
    private final Rotate cameraRotateZ = new Rotate(0, Rotate.Z_AXIS);
    private final Translate cameraTranslate = new Translate(0, 0, -160);

カメラの位置と向きは、Timelineの中でプロパティオブジェクトでバインドできるよう、上で定義したフィールドを持たせます。

        camera.getTransforms().addAll(
            cameraRotateX,
            cameraRotateY,
            cameraRotateZ
            cameraTranslate
        );

getTransforms().addAllで複数の座標変換オブジェクトを指定できます。このとき、指定した順番に変換処理を行います。数学の座標変換行列の積では、最後から変換処理が適用されますが、ここでは引数の最初から順番に変換処理がかかります。上述のコードでは、まずX軸を軸とした回転、次にY軸を軸とした回転、次にZ軸を軸とした回転、それからXYZ座標の平行移動が処理されます。

地球は、赤道面が y=0 の平面で、赤道上空を回る軌道はY軸を軸とした回転で表されます。つまり、カメラが原点にある状態でY軸を軸とした回転を与えることでカメラの向きを変え、次にZ軸方向に平行移動させることでカメラの位置が中心からカメラの向きの前後に移動します。

アニメーションでは、カメラの位置がY軸を中心に時間とともに回転するように移動させ、Z軸方向への平行移動は常に同じ値であればよいので、カメラの向きをKeyFrameで指定する状態とします。

        final Timeline animation = new Timeline();
        animation.getKeyFrames().addAll(
            new KeyFrame(Duration.ZERO,
                         new KeyValue(cameraTranslate.xProperty(), 0)
            ),
            new KeyFrame(Duration.millis(3000),
                         new KeyValue(cameraRotateY.angleProperty(), -90)
            )
        );

これでさあ実行!

動きを伴うプログラムなので、画面キャプチャではなく動画キャプチャをアニメーションGIFで添付します。

カメラの移動(その2)

Timelineでの実装は無理があったようですが、少しだけわるあがきをしてみます。
KeyValueの遷移では、直線補間以外に補間を制御することができます。ここではInterpolate.SPLINEを使って少し曲線っぽく移動をさせてみることにします。ただし、円軌道を表現することはできないので、実際に使用するには毎回SPLINEのパラメータを考えなくてはならず無理があります。

90度の回転時(第4象限)に、x軸方向の増加度合い、z軸方向の増加度合いをスプライン曲線で近似して(制御パラメータはかなり適当に、30度、60度のときのcos、sinの値から)、KeyValueに追加しています。

        final Interpolator fastSlowInterpolator = Interpolator.SPLINE(0.13, 0.5, 0.5, 0.87);
        final Interpolator slowFastInterpolator = Interpolator.SPLINE(0.5, 0.13, 0.87, 0.5);

        animation.getKeyFrames().addAll(
            new KeyFrame(Duration.ZERO,
                         new KeyValue(cameraRotateY.angleProperty(), 0),
                         new KeyValue(cameraTranslate.xProperty(), 0, fastSlowInterpolator),
                         new KeyValue(cameraTranslate.yProperty(), 0),
                         new KeyValue(cameraTranslate.zProperty(), -200, slowFastInterpolator)
            ),
            new KeyFrame(Duration.millis(3000),
                         new KeyValue(cameraRotateY.angleProperty(), -90),
                         new KeyValue(cameraTranslate.xProperty(), 200, fastSlowInterpolator),
                         new KeyValue(cameraTranslate.yProperty(), 0),
                         new KeyValue(cameraTranslate.zProperty(), 0, slowFastInterpolator)
            )
        );

これでさあ実行!

動きを伴うプログラムなので、画面キャプチャではなく動画キャプチャを添付します。

カメラの移動(その3)

Transitionによるアニメーションの実現に切り替えます。

カメラの移動を、ArcToによる円弧(ここでは半円)で定義します。
カメラの向きは、移動がうまくいってからの実装とします。
ArcToは、2次元(XY平面)しか対応していないので、一旦XY平面上に定義してからX軸に90度回転させます。

        // カメラ移動の定義
        // Pathは2次元での定義しかないので!、XY平面で定義後回転させる
        final ArcTo arcTo = new ArcTo();
        arcTo.setX(0);
        arcTo.setY(-200);
        arcTo.setRadiusX(100);
        arcTo.setRadiusY(100);
        final MoveTo moveTo = new MoveTo();
        moveTo.setX(0);
        moveTo.setY(200);
        final Path path = new Path();
        path.getElements().addAll(moveTo, arcTo);
        path.setRotationAxis(Rotate.X_AXIS);
        path.setRotate(-90);
        final PathTransition orbit = new PathTransition();
        orbit.setDuration(Duration.millis(9000));
        orbit.setPath(path);
        orbit.setNode(camera);

コンパイルして実行したところ、X軸方向の移動はしますが、Z軸方向への移動がないような動きとなりました。

ArcToやPathが3次元に対応していないので、2次元上で定義したものをrotateで回転させていますが、ArcToは2次元用なので座標変換時に落ちてしまう(Z軸方向の動きが0になる)のではと仮説を立てました。

検証は今後・・・

カメラの移動(その4)

AnimationTimerによるアニメーションの実現に切り替えます。

AnimationTimerを開始すると、JavaFXが画面をレンダリングする度にコールバックされるので、その中でカメラの位置、角度を計算します。

        timer = new AnimationTimer() {
                @Override
                public void handle(long now) {
                    update(now);
                }
            };
        timer.start();

引数は、System.nanoTimes()の値が渡されるので、それで経過時間を判別します。
AnimationTimerのhandleメソッドは、かなりの高頻度(開発マシン:Windows 7では、15ms間隔)で呼び出されます。

カメラ位置を更新する処理をupdateメソッドにまとめています。
カメラは Y=0 の平面(X-Z平面)で原点を中心に 定数 ORBITAL_RADIUS を半径とした円軌道を移動します。
また、カメラの角度は常に球体を向くように円軌道の移動に合わせて回転します。位置更新は30Hz程度とするため、AnimationTimerの呼び出しを少し間引いています。(前回呼び出されてから33ms以上経過していたらカメラ位置を更新)

    private void update(long now) {
        if (previousHandledTime + 33_000_000 > now) return;
        previousHandledTime = now;
        azimuth += AZIMUTH_SPEED_PER_100MILLIS;

        cameraTranslate.setX(Math.sin(Math.toRadians(azimuth)) * ORBITAL_RADIUS);
        cameraTranslate.setZ(-1 * Math.cos(Math.toRadians(azimuth)) * ORBITAL_RADIUS);
        cameraRotateY.setAngle(-1 * azimuth);
    }

コンパイルして実行したところ、うまい具合に表示されました。

UIコントロールの追加

目の前で動く絵があると、やはりいろいろ制御したくなります。
ここでは、3Dグラフィックス画面(シーングラフ)に、2次元のUIコントロールを加えます。

JavaFXで3Dのシーングラフを作ってモデルやカメラを移動させると、全てが一緒に移動してしまいます。

ボタンなどのUIコントロールの座標系は画面の左上を(0, 0)とした画素数での大きさを持ち、通常カメラ位置に関わらず画面上に同じ大きさで表示します。

一方、3Dグラフィックスの座標系は画素数とは別な単位(例えばメートルとかキロメートル、あるいはその他の単位)であり、遠近法で遠いものは小さく、近いものは大きく表示します。

このように、2Dと3Dは基本的に別ものです。

JavaFXでは、このように基本的には相容れない2つの体系をSubSceneを使って、2DのUIコントロール系と3Dのモデルとを分けて管理します。

今回は次のようなシーングラフを作成しました。

stage:Stage
  +--- scene:Scene
         +--- root:AnchorPane
                +--- earthScene:SubScene
                |      +--- camera:PerspectiveCamera
                |      +--- earthGroup:Group:
                |             +--- earth:Sphere
                |             +--- pointLight:PointLight 
                +--- panel:GridPane
                       +--- azimuthRateLabel:Label
                       +--- azimuthRateSlider:Slider
                       +--- elevationLabel:Label
                       +--- elevationSlider:Slider
                       +--- altitudeLabel:Label
                       +--- altitudeSlider:Slider

完成した表示画面は次になります。

casestudy1_09.png

今回は、3Dの座標系を単位をkmとなるよう変更しています。地球は世界測地系1984(WGS84)で定義する回転楕円体の長半径を半径とする真球(Sphere)で定義します。

    private static final double EARTH_RADIUS = 6378.137 / 2; // WGS84長半径[km]
      :
    final Sphere earth = new Sphere(EARTH_RADIUS);

UIコントロールには、次の3つの表示(アニメーション)を制御するスライダーを設けています。

  • 円軌道を移動するカメラの角速度(度/秒)
  • 円軌道の赤道面からの仰角(度)
  • 円軌道の地球表面からの距離=高度(km)

スライダーを操作した値を格納するプロパティインスタンスをフィールドに定義します。

    private final DoubleProperty azimuthRateProperty = new SimpleDoubleProperty(10d);
    private final DoubleProperty elevationProperty = new SimpleDoubleProperty(0d);
    private final DoubleProperty orbitalAltitudeProperty = new SimpleDoubleProperty(4_000d);

実行している動画(アニメーションGIF)は次になります。

ui_control-2.gif

SubSceneで3D表示する場合の注意点

先のサンプルでは、3Dオブジェクトが1つ(Sphereのみ)だったので気づきませんでしたが、SubSceneの作成に誤りがあります。

SubSceneクラスのコンストラクタは次の2つです。

  • public SubScene(Parent root, double width, double height)
  • public SubScene(Parent root, double width, double height, boolean depthBuffer, SceneAntialiasing antiAliasing)

3D表示では、depthBuffer(奥にある物体を手前にある物体の上に描画しないよう制御する)がtrueでなければなりませんが、前者のコンストラクタでSubSceneを生成すると、depthBufferがfalseとなってしまいます。このフラグはコンストラクタでしか制御できません。

Scene Builderのような画面レイアウトツールでSubSceneを配置し、あとで3DのコンテンツをSubSceneに描画するときは、要注意です。
Scene Builder(Ver. 8.3.0)では、depthBufferをtrueにする方法が見当たらなかったので、Scene Builderが生成するFXMLファイルを直接修正します。

- <SubScene id="scene" fx:id="SubScene" fill="#ddeeff" height="200" width="200">
+ <SubScene id="scene" fx:id="SubScene" fill="#ddeeff" height="200" width="200" depthBuffer="true">

SceneAntialiasingをFXMLで指定する方法は現時点では見いだせていません。

参考

JavaFXの座標系と座標変換
3Dおよびカメラについての解説あり


6年以上前に更新