我们可能在运行前面的动画程序时注意到有“闪烁”的现象,其中很大一部分原因是因为在动画绘制时观看动画。因为如果所绘制的对象与显示器的电子枪不同步,就会得到一个可见的畸变现象,即使绘制整个屏幕只需要几微秒,闪烁现象也会在时间上持续。
如果读者是一个游戏编程的新手,那么欢迎来到屏外绘制的世界。简单地说,屏外绘制(也叫双缓冲)就是把整个画面绘制到一个“虚拟窗体”上,或者说是画到一个只在内存中存在的窗体上。在画面上所有的物体都被绘制到虚拟窗体上之后,它的内容被一次全部绘制到实际的applet窗体上,得到的效果是每次只有帧的图像被绘制到applet窗体上,因此减少了闪烁。
要实际实现一个双缓冲机制,只需在applet的init方法中调用Applet类的createImage方法,这个方法返回一个适合屏外绘制的Image对象。createImage方法有两个参数,分别描述要创建的图像的宽和高。下面的代码创建一个屏外图像,其宽和高与applet窗体相同:
//假设offscreen指向Image类的一个实例
offscreen=createImage(getSize(),width,getSize().height);
然后,在Applet的paint方法中绘制屏外图像。在画面被完全绘制到屏外图像中后,需要把它绘制到主applet窗体上,以便让它可以被看见。下面的代码段演示了一个双缓冲绘制机制的框架。
public void paint(Graphics g){
//从屏外图像中获得一个有效的Graphics2D绘制容器
Graphics2D g2d=(Graphics2D)offscreen.getGraphics();
//使用选择的颜色清除屏外图像的内容
g2d.setPaint(Color.WHITE);
g2d.fillRect(0,0,getSize().width,getSize().height);
//***使用新创建的g2d对象做一些绝妙的材料
//用完后破弃Graphics2D
g2d.dispose();
//把最后的屏外图像画到可视的Graphics容器上
g.drawImage(offscreen,0,0,this);
}
在应用双缓冲时调用Graphics类的dispose方法总是一个好的想法,这样会释放Graphics容器在绘制过程中分配的所有资源。由于连续动画可能导致相当多数量的Graphics对象在很短的时间内被创建,及时手工清除它会帮助保持内存空间并减缓垃圾回收器的运行频率。记住,在Graphics容器的dispose方法被调用后,不能再用它来绘制。
最后需要注意的是,在实际使用屏外图像之前要确保它是有效的。判断一个屏外对象是否有效的一个方法是把它的尺寸和父applet的尺寸进行比较,如果它们不匹配,可能需要重新创建屏外图像。
建议大家在applet的update方法的末尾校验屏外图像,这是因为屏外图像的校验和实际绘制的画面没什么关系,而是和画面绘制的准备过程有关系。下面的例子显示了如何更新一个屏外图像:
public void update(Graphics g){
//***在此更新一些画面对象
//确认屏外图像有效
if(offscreen==null ||
offscreen.getWidth(null)!=getSize().width ||
offscreen.getHeight(null)!=getSize().height){
offscreen=createImage(getSize().width,getSize().height);
}
//绘制画面
paint(g);
}
最后还应检查屏外图像是否为null引用,这是为了防止忘记对它进行初始化。即使在其他的地方校验了屏外图像,覆盖Applet的update方法依然是一个很好的做法,这是因为原始的update方法涉及清空applet窗体,这是对时间的一种极大的浪费,因为总会用屏外图像画在上面。所以,应该尽量定义update方法,即使决定让它空着。
现在已经知道,只要几行代码,就可以完全解决动画的闪烁和晃动。下用一个很相似的例子来回顾一下CollisionTest applet,这个例子用到了双缓冲。下面的OffscreenTest applet的代码清单自动更新一个矩形的位置,并检查它是否和其他9个矩形存在冲突,我们可以通过按空格健来选择激活的矩形。
攻克屏外绘制是很重要的,尤其是如果以前没有用过的话,应用在继续下面内容之前试运行一下这个例子。
import java.applet.*;
import java.awt.*;
import java.awt.geom.*;
import java.awt.event.*;
import java.util.*;
public class OffscreenTest extends Applet implements KeyListener,Runnable{
//动画线程
private Thread animation;
//画面中所包含的矩形的数目
private final int NUM_RECTS=10;
//矩形队列
private LinkedList rectangles;
private ListIterator iterator;
//显示半透明矩形的AlphaCompisite
private AlphaComposite alpha;
//当前所选择的矩形的索引
private int curr;
//运动的矩形的当前位置
private double vx;
private double vy;
//为屏外绘制准备的屏外图像
Image offscreen;
public void init(){
animation=new Thread(this);
rectangles=new LinkedList();
//创建屏外绘制图像
offscreen=createImage(getSize().width,getSize().height);
//用50%的透明度创建一个AlphaComposite
alpha=AlphaComposite.getInstance(AlphaComposite.SRC_OVER,0.5f);
//在随机位置创建NUM_RECTS个矩形并把它们添加到队列中
Random r=new Random();
int width=(int)getSize().getWidth();
int height=(int)getSize().getHeight();
for(int i=0;i<NUM_RECTS;i++){
rectangles.add(new Rectangle2D.Double(
(double)(Math.abs(r.nextInt())%width),
(double)(Math.abs(r.nextInt())%height),
(double)(Math.abs(r.nextInt())%50),
(double)(Math.abs(r.nextInt())%50)));
}
curr=0;
vx=vy=6;
//不要忘记注册applet来监听键盘事件
this.addKeyListener(this);
}//init
public void update(Graphics g){
//更新当前矩形
double x,y,w,h;
Rectangle2D active=(Rectangle2D)rectangles.get(curr);
x=active.getX()+vx;
y=active.getY()+vy;
w=active.getWidth();
h=active.getHeight();
if(x<0){
x=0;
vx=-vx;
}else if(x+w>getSize().width){
x=getSize().width-w;
vx=-vx;
}
if(y<0){
y=0;
vy=-vy;
}else if(y+h>getSize().height){
y=getSize().height-h;
vy=-vy;
}
active.setRect(x,y,w,h);
//确保有一个有效的屏外图像
if(offscreen==null ||
offscreen.getWidth(null)!=getSize().width ||
offscreen.getHeight(null)!=getSize().height){
offscreen=createImage(getSize().width,getSize().height);
}
paint(g);
}//update
public void paint(Graphics g){
Graphics2D g2d=(Graphics2D)offscreen.getGraphics();
g2d.setPaint(Color.white);
g2d.fillRect(0,0,getSize().width,getSize().height);
//告诉Graphics2D容器使用透明度
g2d.setComposite(alpha);
//绘制矩形
g2d.setPaint(Color.BLACK);
for(int i=0;i<NUM_RECTS;i++){
g2d.draw((Rectangle2D)rectangles.get(i));
}
Rectangle2D rect;
Rectangle2D active=(Rectangle2D)rectangles.get(curr);
g2d.setPaint(Color.RED.darker());
for(iterator=rectangles.listIterator(0);iterator.hasNext();){
//获得队列中的下一个矩形
rect=(Rectangle2D)iterator.next();
//检测重叠,注意不应该检测所选取的矩形和它自身的重叠
if(active!=rect&&active.intersects(rect)){
//填充重叠部分
g2d.fill(rect);
}
}
//填充所选取的矩形
g2d.setPaint(Color.BLUE.brighter());
g2d.fill(active);
//在用完后回收Graphics2D容器
g2d.dispose();
//在可见的Graphics容器上绘制最终的屏外图像
g.drawImage(offscreen,0,0,this);
}//paint
public void start(){
//启动动画线程
animation=new Thread(this);
animation.start();
}//start
public void stop(){
animation=null;
}//start
public void run(){
Thread t=Thread.currentThread();
while(t==animation){
try{
Thread.sleep(33);
}catch(InterruptedException e){
break;
}
repaint();
}
}//run
public void keyPressed(KeyEvent e){
}
public void keyReleased(KeyEvent e){
}
public void keyTyped(KeyEvent e){
//如果按下空格键则在矩形之间循环
if(e.getKeyChar()==KeyEvent.VK_SPACE){
if(++curr>=rectangles.size()){
curr=0;
}
}
}
}// OffscreenTest
注意:如果在游戏中选择swing组件(虽然不推荐这么做),则可能不需要实现屏外绘制方式,因为swing组件已经实现了它们自己的双缓冲机制。
9.3.1 创建BufferedGraphics类
虽然在Java中实现一个屏外绘制系统是很简单的事情,但是把组成它的组件封装为一个类仍然是一个好主意。这样在主applet类中将避免一些间接调用,但是正如后面所讲的那样,它会允许用户通过子类扩展其功能。
编写BufferedGraphics的代码应该不费劲,所需要做的只是对它的Component的所有者(通常是主applet)以及扮演屏外绘制图像的Image对象维护一个引用。为了实现BufferedGraphics类,必须实现创建,校验以及返回屏外图像和它的Graphics容器的方法。
下面来看看BufferedGraphics类的代码:
import java.applet.*;
import java.awt.*;
public class BufferedGraphics extends Object{
//用来绘制屏外图像的Component
protected Component parent;
//屏外绘制的Image
protected Image buffer;
//创建一个新的BufferedGraphics对象
protected BufferedGraphics(){
parent=null;
buffer=null;
}
//用所传入的父Component创建新的BufferedGraphics对象
public BufferedGraphics(Component c){
parent=c;
createBuffer();
}
public final Image getBuffer(){
return buffer;
}
//在校验完缓冲区后返回缓冲区的Graphics容器
public Graphics getValidGraphics(){
if(!isValid()){
createBuffer();
}
return buffer.getGraphics();
}
//用parent的宽和高来创建一个屏外绘制图像
protected void createBuffer(){
Dimension size=parent.getSize();
buffer=parent.createImage(size.width,size.height);
}
//检验屏外图像,防止两个严重的问题:空引用和大小不一致
protected boolean isValid(){
if(parent==null){
return false;
}
Dimension s=parent.getSize();
if(buffer==null ||
buffer.getWidth(null)!=s.width ||
buffer.getHeight(null)!=s.height){
return false;
}
return true;
}//isValid
}//BufferedGraphics
9.3.2 通过VolatileImage类使用硬件加速
如果开发者在它们的应用程序中使用比较复杂的绘制算法,那么这将对CPU带来比较大的消耗。
Java语言的设计者发明了一种利用硬件加速的方法来克服对处理器的消耗。大多数使用Windows的PC玩家对于AGP视频加速卡形式的硬件加速是比较熟悉的,这些加速卡除了有计算单元外还有内存,它们和系统处理器以及RAM一起工作。通过承担复杂图形计算的工作,视频卡可以极大地增强应用程序的性能。
Java的架构设计师在Java 1.4中设计了VolatileImage类来利用这种加速的优点。在windows平台上,这意味着直接把图像存储到视频RAM(VRAM)中,并允许像DirectDraw这样的软件包访问这个内存。在不提供对VRAM进行访问的平台,如Solaris上,VolatileImage类通过X Server上的映射对象来访问图像。简言之,VolatileImage类使用当前平台提供的最快的方法来访问图像。
VolatileImage类的一个问题是,图像内容随时可能丢失,换句话说,图像是不稳定的,就如它的名字那样(volatile:挥发性的)。在Windows中,激活一个屏保,更换分辨率,或者以全屏独占方式运行另一个程序,都可能使得VolatileImage对象的内容丢失。
最不幸的是,当Windows清除VolatileImage对象时的内容时并不发送任何消息。在这种情况下,当前画面必须重绘以恢复图像的内容。
下面的VolatileImageTest applet,允许用户选择是用标准方式还是用硬件加速方式绘制屏外图像,程序会报告把一个简单画面绘制1000次所用的时间。
import java.applet.*;
import java.awt.*;
import java.awt.event.*;
import java.awt.image.*;
import java.awt.geom.*;
public class VolatileImageTest extends Applet implements ItemListener{
//动画线程
private Thread animation;
//屏外绘制图像
private Image offscreen;
//平铺图像
private Image tile;
//平铺图像的宽和高
private int tileWidth;
private int tileHeight;
//允许用户在屏外绘制加速和不加速中作出选择
private Checkbox accelerated;
public void init(){
//创建平铺图像
tile=getImage(getDocumentBase(),"graytile.gif");
//默然:注意这一行代码,如果你的图像名称不对,你的applet将会进入一个死循环
while(tile.getWidth(this)<=0);
tileWidth=tile.getWidth(this);
tileHeight=tile.getHeight(this);
//创建单选按钮
setLayout(new BorderLayout());
accelerated=new Checkbox("使用被加速的图像",null,true);
accelerated.addItemListener(this);
Panel p=new Panel();
p.add(accelerated);
add(p,BorderLayout.SOUTH);
//创建屏外绘制图像
createOffscreenImage(accelerated.getState());
}//init
//根据传入的参数创建VolatileImage或者BufferedImage
private void createOffscreenImage(boolean createAccelerated){
if(createAccelerated){
//创建加速图像
offscreen=getGraphicsConfiguration()
.createCompatibleVolatileImage(getSize().width,getSize().height);
}else{
//否则,只是创建一个老式的BufferedImage
offscreen=getGraphicsConfiguration()
.createCompatibleImage(getSize().width,getSize().height);
}
}
public void update(Graphics g){
//计算绘制1000次画面所耗费的时间
long time=System.currentTimeMillis();
for(int i=0;i<1000;i++){
paint(g);
}
if(offscreen instanceof VolatileImage){
System.out.println("使用加速图像绘制1000次共花去了:"+(System.currentTimeMillis()-time)+"毫秒");
}else{
System.out.println("使用非加速图像绘制1000次共花去了:"+(System.currentTimeMillis()-time)+"毫秒");
}
}//update
public void paint(Graphics g){
//检验屏外图像并绘制画面
if(offscreen instanceof VolatileImage){
VolatileImage volatileImage=(VolatileImage)offscreen;
do{
//如果屏外图像无效,则重置它
if(volatileImage.validate(getGraphicsConfiguration())
==VolatileImage.IMAGE_INCOMPATIBLE){
createOffscreenImage(true);
}
//绘制画面
paintScene(volatileImage.getGraphics());
//如果内容丢失则循环
}while(volatileImage.contentsLost());
}else{
if(offscreen==null ||
offscreen.getWidth(null)!=getSize().width ||
offscreen.getHeight(null)!=getSize().height){
createOffscreenImage(false);
}
paintScene(offscreen.getGraphics());
}
//在applet窗体上绘制屏外图像
g.drawImage(offscreen,0,0,this);
}//paint
private void paintScene(Graphics g){
//在applet窗体内平铺图像
Graphics2D g2d=(Graphics2D)g;
int width=getSize().width;
int height=getSize().height;
for(int y=0;y<height;y+=tileHeight){
for(int x=0;x<width;x+=tileWidth){
g2d.drawImage(tile,x,y,this);
}
}
//破弃Graphics对象可能使用的资源
g2d.dispose();
}
public void itemStateChanged(ItemEvent e){
if(accelerated==e.getSource()){
createOffscreenImage(accelerated.getState());
repaint();
}
}
}//VolatileImageTest
默然:我把这个程序在一台不带硬件加速的机器使用了之后,发现当使用加速图像时速度反而变慢了,不知道在带硬件加速的机器上使用是个什么情况。不过也得出一个结论,硬件加速的确是需要条件的,不见得是最佳的选择。
为了节省页面,这里没有从头开始一步步展实现加速屏外绘制图像。然而,在发布自己的游戏时,一定要遵循步骤,并进行所有在不稳定的图像上必需的检测工作。在第11章中,我们还会学习如何让程序自动选择是否实现硬件加速。目前而言,如果系统包含加速硬件,试着在程序中使用它们,这对速度上的提升可能会很明显。
默然:另外,应注意如何在循环中包含硬件加速。对于VolatileImage类的了解是非常必要的,希望大家仔细查看Java API文档。
现在,我们已经学习了使用Java创建动画所必需的大部分概念。线程,图像矩阵以及屏外绘制图像都是动画的必要组成部分。我们还没有讨论一个主题:动画调整,或者说动画同步,其目的是让动画按照规定的时间间隔执行。
使用,假设刚开发了一个Java迷宫游戏,在老式的166MHz奔腾I的机器上它可能运行良好,然而,随后可能发现在崭新的2GHz的机器上游戏快得让人没法玩。这是为什么?这是因为没有让帧以同样的速度运行。
在迷宫游戏中,游戏的最低需求不是问题,而最高需求则成为问题。但幸运的是,今天大多数游戏都实现了某种动画同步技术来保证它在所有的系统中都能运行。
要维持不变的帧速,需要做的只是确保每一帧用同样的时间来完全执行。所以,如果能保证每一帧都用恰好20ms来运行的话,那么一秒钟可以执行50个这样的循环,所以,可以保持50帧每秒(fps)的恒定帧速。
一旦决定让游戏采用恒定帧速运行,可以轻易计算出每一帧的最小时间为:
每一帧的毫秒数(one_frame_ms)=1000/目标帧速(target_framerate)
如果用户已经是一个经验丰富的C/C++程序员,很可能已经在游戏中实现过帧速同步机制。在Java中,可以在主游戏循环——也就是使用run方法中相类似的机制。如下:
(1)循环(死循环)
(2)保存当前时间t0
(3)更新或者绘制画面。
(4)保存当前时间t1
(5)计算用去的时间dt=t1-t0
(6)如果dt小于one_frame_ms,调用Thread.sleep,休眠(one_frame_ms-dt)
(7)或者,让当前执行线程休眠几毫秒
例如,如果one_frame_ms被设为25ms(每秒40帧),但是给定的帧只用了18ms完成了更新和绘制,那么,需要休眠7ms来确保帧速恒定。
当更新和绘制帧需要的时间大于one_frame_ms时,帧速同步会发生问题。所以,如果one_frame_ms被设为15而在慢一点的机器上更新和绘制帧用了25ms,帧速就不能保证是恒定的。而且,如果让当前线程不休眠,带来的另一个严重问题是:垃圾收集器会一直挂起,最后导致可用内存不够用,系统锁死。所以,即使帧用了太长时间来更新绘制,也必须允许当前线程休眠至少几毫秒的原因。
下面的FramerateTest applet,什么也不做,只是维持60fps的恒定帧速。在run循环的后面,计算了帧数和用去的时间,这样可以每秒报告一次实际的帧速。
import java.applet.*;
import java.awt.*;
public class FramerateTest extends Applet implements Runnable{
//动画线程
private Thread animation;
//每一帧的最短毫秒数
private long framerate;
public void init(){
setBackground(Color.BLACK);
animation=new Thread(this);
//设置帧速为每秒60帧(16.67ms/frame)
framerate=1000/60;
}
public void start(){
//开启动画线程
animation.start();
}
public void stop(){
animation=null;
}
public void run(){
//帧开始时间
long frameStart;
//这一秒的帧数
long frameCount=0;
//一帧消耗的时间
long elapsedTime;
//多帧累计消耗的时间
long totalElapsedTime=0;
//实际计算出来的帧速
long reportedFramerate;
Thread t=Thread.currentThread();
while(t==animation){
//保存开始时间
frameStart=System.currentTimeMillis();
//绘制帧
// repaint();
//计算绘制帧所消耗的时间
elapsedTime=System.currentTimeMillis()-frameStart;
//帧同步
try{
//确保这一帧已经过去了framerate毫秒
if(elapsedTime<framerate){
Thread.sleep(framerate-elapsedTime);
}else{
//不能让垃圾收集器一直挂起
Thread.sleep(5);
}
}catch(InterruptedException e){
break;
}
//更新实际的帧速
++frameCount;
totalElapsedTime+=(System.currentTimeMillis()-frameStart);
if(totalElapsedTime>1000){
reportedFramerate=(long)((double)frameCount/
(double)totalElapsedTime*1000.0);
//在applet的状态栏中显示帧速
showStatus("fps:"+reportedFramerate);
frameCount=0;
totalElapsedTime=0;
}
}
}//run
}//FramerateTest
注意:基于帧的动画需要至少20fps的速度运行,这样才会让眼睛相信动画是真的连续的。而且,最慢的显示器本身的刷新速度是大约60fps。因此,通常需要选择一个介于这两个值之间的帧速。如果帧速设置太低,眼睛会认为动画是不可信的。另一方面,如果帧速设置太高,帧会以比显示器可以绘制的速度高很多的速度发给射线,结果是产生帧丢失。
注意当前的帧速通过showStatus方法显示在applet的状态栏中。在本章的练习中,将被要求修改FramerateTest applet,以让用户任何时候都可以修改帧速。
9
.4 总结
本章介绍了一些使画面动起来的有趣的方法。MediaTracker类提供了一种很好的远程加载并检查任何可能的错误的方法。位图字体的使用是测试图像条以及创建自定义文本绘制系统的好方法。还有,实现双缓冲系统是实现无闪烁动画的根本。最后,帧速同步理所当然地会通过产生恒定速度的动画而使动画完美。试着做下面的练习,你会很快成为动画专家。
9
.5 练习
9.1设计一种方法,不使用MediaTracker对象而检测正在加载的图像的状态。(提示:使用一个循环,Image对象有一个属性只有完全被加载才会被设置。)这样做有什么潜在的问题?
9.2修改loadImageStrip方法,让它可以正确加载“不正常”的图像文件——这些文件中包含不同尺寸的单元格或者有额外的空白或者填充。
9.3把FontMap类的键值的类型改为Character。从表中获取值的代码的变化应该尽量小。你倾向于使用哪种方法?哪一种方法更具有灵活性?
9.4引入一种实现在格式化位图数字时“填充”的方法。比如,把数字29填充为5位会被绘制为“00029”。填充一个字符串的方法的原型可能是:
drawPaddedString(String s,int preferredLength,char padChar,Graphics2D g2d)
这里,s是要绘制的字符串,preferredLength是绘制字符数目的最小数目,padChar表示在短字符串前填充的字符,而g2d则指向当前的Graphics2D绘制容器。你还可以对方法添加其他表示绘制位置的(x,y)坐标的参数,其他的变换等。在FontMap类中添加这个drawPaddedString方法作为绘制填充字符串的工具。
9.5我们已经看到了如何使用位图字体绘制负数。扩展这个实现一个绘制完整的字符表的机制,其中应该包括像“%”,“$”和“*”这样被广泛使用的字符。最终的位图字体类应该提供以位图字符串的形式绘制字符串,整数,浮点数和双精度数的功能。
9.6研究一下ImageCapabilities类,主要是isAccelerate方法,修改VolatileImageTest applet在选择屏外绘制机制前测试视频加速能力。当前运行的操作系统的图像能力可以使用Component类的getGraphicsConfiguration方法得到。
9.7在FramerateTest applet中添加一个TextField组件来允许用户在任何时间选择帧速,而且,尝试允许用户添加一个applet参数在启动时设定初始帧速。