Java软光栅渲染器-创建窗口

目标

  1. 创建一个Java工程。
  2. 创建主窗口,用于显示画面。
  3. 创建一个实时更新的主循环。
  4. 计算并显示FPS。

实现

主窗口

在实现渲染器之前,首先要创建一个窗口,解决图像的输出问题。我的选择是把Swing的JFrame作为主窗口,Canvas用来显示图像。

先创建一个Java工程,建立net.jmecn包,然后创建Screen类。

package net.jmecn;

import java.awt.Canvas;
import java.awt.Dimension;
import java.awt.Toolkit;

import javax.swing.JFrame;

/**
 * 代表显示图像的窗口
 * 
 * @author yanmaoyuan
 *
 */
public class Screen {
    
    // 主窗口
    private JFrame frame;
    
    // 画布
    private Canvas canvas;
           
    public Screen(int width, int height, String title) {
        canvas = new Canvas();
        
        // 设置画布的尺寸
        Dimension size = new Dimension(width, height);
        canvas.setPreferredSize(size);
        canvas.setMaximumSize(size);
        canvas.setMinimumSize(size);
        canvas.setFocusable(true);

        // 创建主窗口
        frame = new JFrame();
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setResizable(false);
        frame.setSize(width, height);
        frame.setTitle(title);
        frame.add(canvas);// 设置画布
        frame.pack();
        frame.setVisible(true);
        centerScreen();// 窗口居中
        
        // 焦点集中到画布上,响应用户输入。
        canvas.requestFocus();
    }

    /**
     * 使窗口位于屏幕的中央。
     */
    private void centerScreen() {
        Dimension size = frame.getSize();
        Dimension screen = Toolkit.getDefaultToolkit().getScreenSize();
        int x = (screen.width - size.width) / 2;
        int y = (screen.height - size.height) / 2;
        frame.setLocation(x, y);
    }

}

我还需要一个程序运行的入口,一般定义为Main。

package net.jmecn;

/**
 * 程序运行入口
 * @author yanmaoyuan
 *
 */
public class Main {

    public static void main(String[] args) {
        new Screen(800, 600, "JSoftwareRenderer");
    }
}

执行一下。

实时更新的主循环

创建Application类,让它负责执行主循环,实时更新窗口的画面。注意我使用了System.nanoTime() 来获得系统时间,单位是纳秒(1 / 1000,000,000秒)。

package net.jmecn;

/**
 * 应用程序主类
 * 
 * @author yanmaoyuan
 */
public class Application {

    protected int width;
    protected int height;
    protected String title;

    // 显示器
    private Screen screen;

    // 运行状态
    private boolean isRunning;
    
    /**
     * 构造方法
     */
    public Application() {
        width = 800;
        height = 600;
        title = "JSoftwareRenderer";

        // 初始化运行状态
        isRunning = true;
    }
    
    /**
     * 启动程序
     */
    public void start() {
        
        // 计时器
        long startTime = System.nanoTime();
        long previousTime = System.nanoTime();
        long deltaTime;
        float delta;
        
        screen = new Screen(width, height, title);
        
        // 初始化
        initialize();

        while (isRunning) {
            // 计算间隔时间
            deltaTime = System.nanoTime() - previousTime;
            previousTime = System.nanoTime();
            delta = deltaTime / 1000000000.0f;

            // 更新逻辑
            update(delta);
            
            // 更新画面
            render(delta);
            
        }

        // 计算总运行时间
        long totalTime = System.nanoTime() - startTime;
        System.out.printf("运行总时间:" + totalTime / 1000000000.0f);
    }
    
    /**
     * 初始化
     */
    protected void initialize() {
        // TODO
    }
    
    /**
     * 更新场景
     * @param delta
     */
    protected void update(float delta) {
        // TODO
    }
    
    /**
     * 绘制场景
     * @param delta
     */
    protected void render(float delta) {
        // TODO
    }
    
    /**
     * 停止程序
     */
    public void stop() {
        isRunning = false;
    }
}

比起一次性写完,我更喜欢先把几个生命周期方法先定义好。

start() 和 stop() 是两个public方法,分别负责启动和停止主循环。

initialize() 负责做一些初始化工作。也许我还应该定义一个clean()方法负责做回收工作,不过目前不需要。

update(float delta) 和 render(float delta) 方法由主循环调用。其中update负责更新场景,render负责绘制场景。

现在换一下 Main 类中的代码:

package net.jmecn;

/**
 * 程序运行入口
 * @author yanmaoyuan
 *
 */
public class Main {

    public static void main(String[] args) {
        Application app = new Application();
        app.start();
    }
}

显示FPS

OK,现在主循环已经能够跑起来了,但是画面并没有更新,因为还没有实现 render() 方法。为了让画面能够刷新,需要给Screen类增加一些代码。

首先要改一下 Screen 类构造方法,创建一个与Canvas等大的BufferedImage,作为绘图的对象。然后还要给Canvas开启双缓冲策略,避免画面闪烁。

BufferedImage 和 BufferStrategy 都要作为私有成员添加到 Screen 类中。

// 用于显示的图像
private BufferedImage displayImage;

// Canvas的双缓冲
private BufferStrategy bufferStrategy;

public Screen(int width, int height, String title) {
    canvas = new Canvas();
    
    // 设置画布的尺寸
    Dimension size = new Dimension(width, height);
    canvas.setPreferredSize(size);
    canvas.setMaximumSize(size);
    canvas.setMinimumSize(size);
    canvas.setFocusable(true);

    // 创建主窗口
    frame = new JFrame();
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setResizable(false);
    frame.setSize(width, height);
    frame.setTitle(title);
    frame.add(canvas);// 设置画布
    frame.pack();
    frame.setVisible(true);
    centerScreen();// 窗口居中
    
    // 焦点集中到画布上,响应用户输入。
    canvas.requestFocus();
    
    // 创建缓冲图像
    displayImage = new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR);
    
    // 创建双缓冲
    canvas.createBufferStrategy(2);
    bufferStrategy = canvas.getBufferStrategy();
}

然后,再给Screen类添加一个swapBuffer(int fps)方法,用来刷新画面。

/**
 * 交换缓冲区,将渲染结果刷新到画布上。
 * @param fps
 */
public void swapBuffer(int fps) {
    
    Graphics graphics = bufferStrategy.getDrawGraphics();
    
    // 将BufferedImage绘制到缓冲区
    graphics.drawImage(displayImage, 0, 0, displayImage.getWidth(), displayImage.getHeight(), null);
    
    // 显示帧率
    graphics.setColor(Color.WHITE);
    graphics.drawString("FPS:" + fps, 2, 16);
    
    graphics.dispose();
    
    // 显示图像
    bufferStrategy.show();
}

修改一下 Application 类的 render方法,调用screen.swapBuffer()。

/**
 * 绘制场景
 * 
 * @param delta
 */
protected void render(float delta) {

    // 交换画布缓冲区,显示画面
    screen.swapBuffer((int) (1 / delta));
}

好了,现在重新运行一下 main 。

固定帧率

现在画面已经可以更新了,但是FPS的数字一直在疯狂改变。一方面是因为用 1 / delta 来计算帧率太不准确,另一方面则是因为主循环几乎在空转,delta的值太小了,误差较大。

我希望控制一下画面的刷新率,比如每秒120帧左右就是个不错的选择。可以在 while 主循环中加一个时间判断,如果delta的值太小,就让主线程休眠一段时间,这样应该就能让帧率降下来。

在 Application 中添加两个私有成员。

// 固定帧率
private boolean fixedFrameRate;
private long fixedTime;

fixedFrameRate 表示是否使用固定帧率来执行主循环,fixedTime则是使用固定帧率执行时主循环应该等待的时间。fixedTime的单位是纳秒(1 / 1000,000,000秒)。

再添加一个public方法,用来设置帧率。

/**
 * 设置固定帧率
 * 
 * @param rate
 */
public void setFrameRate(int rate) {
    if (rate <= 0) {
        this.fixedFrameRate = false;
    } else {
        this.fixedFrameRate = true;
        this.fixedTime = 1000000000 / rate;
    }
}

把 start() 方法中的主循环改进一下。开启固定帧率功能时,先比较deltaTime与fixedTime的大小,然后计算线程需要休眠的时间。注意Thread.Sleep方法的单位是毫秒,1毫秒 = 1000,000 纳秒。

/**
 * 启动程序
 */
public void start() {

    // 计时器
    long startTime = System.nanoTime();
    long previousTime = System.nanoTime();
    long deltaTime;
    float delta;

    screen = new Screen(width, height, title);

    // 初始化
    initialize();

    while (isRunning) {
        // 计算间隔时间
        deltaTime = System.nanoTime() - previousTime;
        
        // 如果使用固定帧率
        if (fixedFrameRate && deltaTime < fixedTime) {
            // 线程等待时间(纳秒)
            long waitTime = fixedTime - deltaTime;

            long millis = waitTime / 1000000;
            long nanos = waitTime - millis * 1000000;
            try {
                Thread.sleep(millis, (int) nanos);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 重新计算间隔时间
            deltaTime = System.nanoTime() - previousTime;
        }
        
        previousTime = System.nanoTime();
        delta = deltaTime / 1000000000.0f;

        // 更新逻辑
        update(delta);

        // 更新画面
        render(delta);

    }

    // 计算总运行时间
    long totalTime = System.nanoTime() - startTime;
    System.out.printf("运行总时间:" + totalTime / 1000000000.0f);
}

修改一下构造方法,默认关闭这个固定帧率功能,把开启的决定交给客户端代码。

/**
 * 构造方法
 */
public Application() {
    width = 800;
    height = 600;
    title = "JSoftwareRenderer";
    
    // 改变运行状态
    isRunning = true;
    
    // 关闭固定帧率
    setFrameRate(0);
}

在 Main 中开启固定帧率功能。

package net.jmecn;

/**
 * 程序运行入口
 * @author yanmaoyuan
 *
 */
public class Main {

    public static void main(String[] args) {
        Application app = new Application();
        app.setFrameRate(120);
        app.start();
    }
}

可以看到FPS在120左右变化。

统计FPS

虽然已经实现了固定帧率功能,但是FPS还是跳动得太快了,主要是 1 / delta 这种计算方式的误差太大。

我想让FPS稳定下来。可以用一个队列统计最近 N 帧的FPS,然后求其平均值,这样FPS就会相对稳定一些。

在 Application 中再加入一些私有成员:

// 帧率(FPS)
private int framePerSecond;
// FPS队列
private final static int QUEUE_LENGTH = 60;
private float[] fps = new float[QUEUE_LENGTH];

然后增加一个私有方法,用来统计FPS。

/**
 * 更新FPS
 */
private void updateFramePerSecond(float delta) {
    // 队列左移
    for (int i = 0; i < QUEUE_LENGTH - 1; i++) {
        fps[i] = fps[i + 1];
    }
    // 当前帧入列
    fps[QUEUE_LENGTH - 1] = 1 / delta;

    // 统计不为0的帧数
    int count = 0;
    int sum = 0;
    for (int i = 0; i < QUEUE_LENGTH; i++) {
        if (fps[i] > 0) {
            count++;
            sum += fps[i];
        }
    }

    // 求平均值
    framePerSecond = (int) (sum / count);
}

这个方法将在 start() 方法的 while 循环中被调用。由于源代码太长,我只粘贴一部分示意代码:

/**
 * 启动程序
 */
public void start() {

    // ...

    // 初始化
    initialize();

    while (isRunning) {
        // 计算间隔时间
        deltaTime = System.nanoTime() - previousTime;
        
        // ...
                    
        previousTime = System.nanoTime();
        delta = deltaTime / 1000000000.0f;

        // 更新FPS
        updateFramePerSecond(delta); // <--------
        
        // 更新逻辑
        update(delta);

        // 更新画面
        render(delta);

    }

    // 计算总运行时间..
}

也别忘了,把render方法中的参数改一下,别再用 1/delta 了。

/**
 * 绘制场景
 * 
 * @param delta
 */
protected void render(float delta) {

    // 交换画布缓冲区,显示画面
    screen.swapBuffer(framePerSecond);
}

重新执行 main 方法,可以看到现在FPS稳定多了。

还有吗?

最后,出于习惯,我想给Application再添加两个方法。

/**
 * 设置分辨率
 * @param width
 * @param height
 */
public void setResolution(int width, int height) {
    this.width = width;
    this.height = height;
}

/**
 * 设置标题
 * @param title
 */
public void setTitle(String title) {
    this.title = title;
}

这样我就能在 Main 中控制主窗口的大小和标题了。

package net.jmecn;

/**
 * 程序运行入口
 * @author yanmaoyuan
 *
 */
public class Main {

    public static void main(String[] args) {
        Application app = new Application();
        app.setResolution(720, 405);
        app.setTitle("Java软光栅渲染器 - Main");
        app.setFrameRate(120);
        app.start();
    }
}

运行一下:

总结

目标达成。