2012年8月23日木曜日

java : 今のjavaはシューティングゲーム製作に使えるらしい

ずいぶん前、多分Windows98SEのころだと思うんですが、javaで簡単なシューティングゲームを作ろうとして取っ掛かりで躓いた苦い思い出があります。 躓いた理由はFULL GCです。 当時のjavaはシューティングゲームやアクションゲームを動かす事を全く想定されていませんでした。 けっこうな頻度でFULL GCが動き、そのたびにガクッと止まってゲームになりませんでした。 シューティングゲームを作るのは絶望的な状況。 それ以来自分の中に「javaでゲームを作るのは無理」という固定観念がありました。

その後javaでゲームを作ろうなどとは考えることも無かったのでそのままの知識を持って今まで来ました。 しかし、ちょっと思い立って「当時と今とは当然状況は違うはず」ということで、簡単なサンプルコードを作って「今でも邪魔なFULL GCはあるのか?」についてのみ調べてみました。

結論から言えば、無茶な弾幕シューティングですら普通に作れそうです。

サンプルコードの概要は「60fpsでたくさんのImageをランダムに描画してみる」という単純なものです。 128×128ピクセルのこの画像をGraphics2D.drawImageしてコマ落ちなしで描画できるかを確認します。

画像は普通に描いてからアルファ値70%にして保存してあります。 拡大縮小(50~150%、最初だけ0%から拡大)回転して11000個描画したときのスナップショットはこんな感じ。

静止画だとわけが分からない絵面ですが、実際動いているのを見ると...やっぱりわけが分からないですね。 用意する絵が適当すぎたような気がします。 まぁ、動いてますよと。

試した環境は

  • OS : Windows7 home
  • CPU : Intel Core i7-2600
  • グラフィックカード : GeForce GTX 560(1GB)
  • jdk : version 1.7

最初は「500個くらい描画できたら弾幕シューティングだって作れるよなぁ」ってことで試しました。 最初の数フレームはコマ落ちしてたけどそれ以降は安定動作。 数分の動作ではFULL GCはありませんでした。 (-verbose:gc -XX:+PrintGCDetailsのコマンドラインオプションで確認。) そして1フレーム描画する所要時間は1ミリ秒。 ず~っと動かしているとたま~にコマ落ちすることはあるようですが、昔のFULL GCのときと違ってゲームに支障は無さそうです。 最初の数フレームでJITが働いたんですかね?

で、「500個で安定動作ってすごいねぇ。どこまで増やせるんだろう?」と調子にのって数を増やしみました。 どんどん数を増やしていったら、GCの確認なしなら11000個まではたまにコマ落ちしながらもだいたい60fpsで動いてました。 GCの確認を入れたら1フレームに18ミリ秒くらいかかって毎回コマ落ちです。 しかし10分くらいの動作ではFULL GCは発生せず。 普通のゲームでは1000個描画することだって無いでしょうから、ずいぶん余裕がありますね?

とはいえ、ここまで来るとなんかコードの方が間違っている気がするなぁ。 まぁいいや。 12000まで増やすとGCの確認なしでも60fpsはキープできませんでした。

絵だけ描いてこの結果なんで、その他のゲームシステムや音関係のコードを入れたら余裕は減るでしょう。 とはいえ、ゲームシステムの方については「newを減らす」とかの基本的な工夫をすればなんとかなりそうですね。 音の方は全く分からないけど...とにかく、javaでシューティングゲームは普通に作れそうです。

以下サンプルコードです。 まずは本体。

// FpsTest.java
// java -verbose:gc -XX:+PrintGCDetails -jar FpsTest.jar
package fpstest;

import java.awt.Color;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.io.File;
import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.imageio.ImageIO;
import javax.swing.JFrame;
import javax.swing.JPanel;

public class FpsTest
{
    private static final String IMAGE_FILE_NAME = "test.png";
    public static final int WIDTH = 800;
    public static final int HEIGHT = 600;
    public static final int INTERVAL = 16; // ms
    public static final int ITEMS_NUMBER = 11000;

    private JPanel _panel;
    private Image _img;
    private Item[] _items;
    private SimpleProfiler _simpleProfiler;

    public static void main(String[] args)
    {
        FpsTest thisApp = new FpsTest();
    }

    private FpsTest()
    {
        _simpleProfiler = new SimpleProfiler();
        loadImage();
        AppRand.initialize();
        initializeItems();
        initializeComponent();
        initializeTimer();
    }

    private void initializeComponent()
    {
        JFrame frame = new JFrame();
        Container container = frame.getContentPane();
        container.setPreferredSize(new Dimension(WIDTH, HEIGHT) );

        _panel = new JPanel()
        {
            @Override public void paintComponent(Graphics g)
            {
                paintHandler( (Graphics2D)g);
            }
        };
        _panel.setDoubleBuffered(true);
        container.add(_panel);

        frame.setResizable(false);
        frame.pack();
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setVisible(true);
    }

    private void initializeTimer()
    {
        AppTimer timer = new AppTimer(INTERVAL, new ActionListener()
        {
            @Override public void actionPerformed(ActionEvent e)
            {
                timerHandler();
            }
        } );
        timer.start();
    }

    private void loadImage()
    {
        try
        {
            _img = ImageIO.read(new File(IMAGE_FILE_NAME) );
        }
        catch (IOException ex)
        {
            Logger.getLogger(FpsTest.class.getName()).log(Level.SEVERE, null, ex);
        }
    }

    private void initializeItems()
    {
        _items = new Item[ITEMS_NUMBER];
        for(int i = 0; i < ITEMS_NUMBER; i++)
            _items[i] = new Item(_img.getWidth(null) / 2, WIDTH, HEIGHT);
    }

    private void timerHandler()
    {
        for(Item item : _items)
            item.next();

        _panel.repaint();
    }

    private void paintHandler(Graphics2D g)
    {
        _simpleProfiler.beginMethod();

        g.setColor(Color.BLACK);
        g.fillRect(0, 0, WIDTH, HEIGHT);

        for(Item item : _items)
            g.drawImage(_img, item.getAffineTransform(), null);

        _simpleProfiler.endMethod();
    }
}

次は描画するオブジェクトの位置を計算するクラスです。 適当。

// Item.java
package fpstest;

import java.awt.geom.AffineTransform;

public final class Item
{
    private int _radius;
    private int _frameWidth;
    private int _frameHeight;

    private double _x;
    private double _y;
    private double _direction;
    private double _rotation;
    private double _rotationSpeed;
    private double _moveSpeed;
    private double _scale;
    private double _scaleAcceleration;

    public Item(int radius, int frameWidth, int frameHeight)
    {
        _radius = radius;
        _frameWidth = frameWidth;
        _frameHeight = frameHeight;

        reset();
    }

    public void reset()
    {
        _x = AppRand.next(0, _frameWidth);
        _y = AppRand.next(0, _frameHeight);
        _direction = AppRand.next(0, 2.0 * Math.PI);
        _rotation = AppRand.next(0, 2.0 * Math.PI);
        _rotationSpeed = AppRand.next(Math.PI / 90, Math.PI / 30);
        _moveSpeed = AppRand.next(2, 5);
        _scale = 0;
        _scaleAcceleration = AppRand.next(0.01, 0.04);
    }

    public void next()
    {
        _scale += _scaleAcceleration;
        if(1.5 < _scale && 0 < _scaleAcceleration)
            _scaleAcceleration = -AppRand.next(0.01, 0.04);
        else if(_scale < 0.5 && _scaleAcceleration < 0)
            _scaleAcceleration = AppRand.next(0.01, 0.04);

        _x += _moveSpeed * Math.cos(_direction);
        _y += _moveSpeed * Math.sin(_direction);
        _rotation += _rotationSpeed;

        double r = _radius * _scale;
        if(_x < -r || _frameWidth + r < _x || _y < -r || _frameHeight + r < _y)
            reset();
    }

    public AffineTransform getAffineTransform()
    {
        AffineTransform res = new AffineTransform();
        res.translate(_x, _y);
        res.scale(_scale, _scale);
        res.rotate(_rotation);
        res.translate(-_radius, -_radius);
        return res;
    }
}

SimpleProfilerクラスは描画メソッドが何ミリ秒ごとに実行されているか、描画メソッドに何ミリ秒かかっているかのおおよその値を計算しています。

// SimpleProfiler.java
package fpstest;

import java.util.ArrayDeque;

public class SimpleProfiler
{
    public static final int HISTORY_LIMIT = 256;
    public static final int PRINT_INTERVAL = 60 * 3;
    public static final long DESIRED_DURATION = 16;

    private ArrayDeque<TimeData> _deque;
    private long _last;

    private int _timeOverCount;
    private int _count;

    public SimpleProfiler()
    {
        _deque = new ArrayDeque<>();
    }

    public void beginMethod()
    {
        if(_last == 0)
        {
            _last = System.currentTimeMillis();
            return;
        }

        long now = System.currentTimeMillis();
        _deque.addLast(new TimeData(now - _last) );
        if(HISTORY_LIMIT < _deque.size() )
            _deque.removeFirst();

        _last = now;
    }

    public void endMethod()
    {
        TimeData timeData = _deque.peekLast();
        if(timeData == null)
            return;

        long duration = System.currentTimeMillis() - _last;
        timeData.duration = duration;

        if(DESIRED_DURATION < duration)
        {
            _timeOverCount++;
            System.out.println("time over. duration = " + duration);
        }

        _count++;
        if(PRINT_INTERVAL <= _count)
        {
            _count = 0;
            print();
        }
    }

    private void print()
    {
        long intervalAverage = 0;
        long durationAverage = 0;

        for(TimeData timeData : _deque)
        {
            intervalAverage += timeData.interval;
            durationAverage += timeData.duration;
        }

        int size = _deque.size();
        intervalAverage /= size;
        durationAverage /= size;

        System.out.println("interval = " + intervalAverage + ", duration = " + durationAverage + ", time over count = " + _timeOverCount);
    }

    private static class TimeData
    {
        public long interval;
        public long duration;

        public TimeData(long interval)
        {
            this.interval = interval;
        }
    }
}

SwingのTimerクラスは精度が低すぎて60fpsのタイミングを取れなかったので簡単なTimerクラスを自作。 sleep時間は適当につき注意。

// AppTimer.java
package fpstest;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.SwingUtilities;

public class AppTimer implements Runnable
{
    private volatile Thread _thread;
    private int _delay;
    private ActionListener _listener;

    public AppTimer(int delay, ActionListener listener)
    {
        _delay = delay;
        _listener = listener;
    }

    public void start()
    {
        _thread = new Thread(this);
        _thread.start();
    }

    public void stop()
    {
        _thread = null;
    }

    @Override public void run()
    {
        Thread thisThread = Thread.currentThread();
        long last = System.currentTimeMillis();

        while(thisThread == _thread)
        {
            long now = System.currentTimeMillis();
            if(_delay <= now - last)
            {
                fireActionEvent();
                last = now;
            }
            try
            {
                Thread.sleep(1); // 適当注意***********************************
            }
            catch (InterruptedException ex)
            {
                Logger.getLogger(AppTimer.class.getName()).log(Level.SEVERE, null, ex);
            }
        }
    }

    private void fireActionEvent()
    {
        SwingUtilities.invokeLater(new Runnable()
        {
            @Override public void run()
            {
                _listener.actionPerformed(new ActionEvent(this, 0, null) );
            }
        } );
    }
}

乱数クラスのラッパー。 要らないような? 何で作ったんだろう?

// AppRand.java
package fpstest;

import java.util.Random;

public class AppRand
{
    private static Random _rand;

    public static void initialize()
    {
        _rand = new Random(System.currentTimeMillis() );
    }

    public static double next(double min, double max)
    {
        return (_rand.nextDouble() - min) * (max - min);
    }
}