はじめに (対象読者・この記事でわかること)

この記事は、JavaFXで簡単なゲームやアニメーションを作ろうとしているが、画像が正しく表示されなかったり、タイマーが突然止まったりして困っている開発者向けです。
この記事を読むことで、Canvas#drawImage()で画像が重ねて描画される理由、AnimationTimerが意図せず停止するメカニズム、それらを回避するための実装パターンを理解できます。サンプルコードはJava 17+JavaFX 20で動作確認済みです。

前提知識

  • Javaの基本的な文法(ラムダ式、匿名クラス)
  • JavaFXのStage/Scene/Canvasの初期化の流れ
  • 画像リソースをgetResourceAsStream()で読み込めること

drawImageが「暴走」する原因と対策

JavaFXのCanvasは「毎フレーム手動でクリアしない限り、以前の描画が残る」仕様です。初心者のころ「クリア忘れ」で画像が無限に重なって見えた挙動を「暴走」と呼んでいました。
加えて、Canvasの幅・高さをScene BuilderやCSSで百分比指定にしてしまうと、ウィンドウリサイズのたびに内部バッファが再確保され、drawImageの座標系が一瞬ズレて「ジャギる」現象が起きます。
対策は以下の2点です。

  1. 描画前にgraphicsContext.clearRect(0, 0, width, height)で明示的にクリアする
  2. CanvasのサイズはsetWidth()setHeight()で固定値を与え、リサイズ時にはAnimationTimer#stop()→再start()でタイマーをリセットする

AnimationTimerが「止まる」謎と根本解決

ステップ1:最小再現コードで症状を確認

Java
public class SpriteApp extends Application { private final LongProperty last = new SimpleLongProperty(); private double x = 0; @Override public void start(Stage stage) { Canvas canvas = new Canvas(800, 600); GraphicsContext gc = canvas.getGraphicsContext2D(); Image hero = new Image(getClass().getResourceAsStream("hero.png")); AnimationTimer loop = new AnimationTimer() { @Override public void handle(long now) { if (last.get() == 0) last.set(now); double dt = (now - last.get()) / 1_000_000_000.0; last.set(now); x += 200 * dt; // 200 px/sec で右へ gc.clearRect(0, 0, 800, 600); gc.drawImage(hero, x, 100); } }; stage.setScene(new Scene(new StackPane(canvas))); stage.show(); loop.start(); } }

このコードだけでは問題なく動きます。しかし、以下のように「別スレッドでCanvasサイズを変更」すると、再現率100%でタイマーが止まります。

Java
// 別スレッドでウィンドウサイズを変更(悪い例) new Thread(() -> { try { Thread.sleep(3000); } catch (InterruptedException ignore) {} Platform.runLater(() -> canvas.setWidth(1024)); // ここで死ぬ }).start();

ステップ2:原因をデバッガで追う

AnimationTimerの内部実装を見ると、handle()が例外をスローすると、JavaFXアニメーションエンジンが黙ってそのタイマーをアンレジスタする仕様です。
上記コードでsetWidth()を呼ぶと、Canvasの内部バッファ再確保中に一時的にGraphicsContextが無効化され、gc.clearRect()NullPointerExceptionが発生→例外はコンソールに出力されるがアプリケーションは動き続け、タイマーだけが止まったように見えます。

ハマった点とエラー解決

  • 例外がコンソールに出力されるだけで、UI側に何の通知もないため、「なぜ止まったか」が分かりにくい
  • スレッド名を見ても「JavaFX Application Thread」上で動いているため、Platform.runLater()の使い方を疑ってしまう
  • タイマー停止後にloop.start()を呼び直しても、内部状態が「停止中」のままになることがある(JavaFX 20までのバグ)

解決策

  1. handle()内で全例外をcatchし、ログに記録した上で次フレームへ継続させる
  2. Canvasサイズ変更前にloop.stop()を呼び、サイズ確定後に新たにloop.start()する
  3. サイズ変更が頻繁に発生する場合は、Canvasの代わりにPaneImageViewで構成し、レイアウトバインドで追従させる

実装例:

Java
AnimationTimer loop = new AnimationTimer() { @Override public void handle(long now) { try { if (last.get() == 0) last.set(now); double dt = (now - last.get()) / 1_000_000_000.0; last.set(now); x += 200 * dt; gc.clearRect(0, 0, canvas.getWidth(), canvas.getHeight()); gc.drawImage(hero, x, 100); } catch (Exception ex) { ex.printStackTrace(); // ログに出力 // タイマーを止めない } } }; // リサイズ時 stage.widthProperty().addListener((ob, o, n) -> { loop.stop(); canvas.setWidth(n.doubleValue()); loop.start(); });

まとめ

JavaFXでCanvas#drawImage()とAnimationTimerを組み合わせる際、以下の3点を守れば「暴走」「停止」は起きません。

  • 描画前に必ずクリア
  • Canvasサイズ変更はタイマー停止/再開で挟む
  • handle()内で例外が漏れないようtry-catch

この記事を通して、JavaFXの描画・タイマー周りの挙動を深く理解し、安定したゲームループを実装できるようになりました。次回は「スプライトシートによるアニメーション効率化」について書く予定です。

参考資料