目标
- 创建一个Java工程。
- 创建主窗口,用于显示画面。
- 创建一个实时更新的主循环。
- 计算并显示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();
}
}
运行一下:
总结
目标达成。