Monday, January 19, 2009

JAVA 2D Graphics dan Animation (Part 3) - Games Engineering

Getting Rid of Flicker and Tearing
Sekarang kita lanjutkan lagi pembahasan games engineering, pada tulisan sebelumnya animasi yang kita jalankan AnimationTest.java, berjalan dengan baik tapi kok flicker yah animasinya ngga smooth.
Ini terjadi karena kita langsung drawing ke screen secara langsung dan terus menerus, jika kita analogikan seperti ketika kita menggambar pada sebuah kanvas, kemudian dihapus dilanjutkan dengan drawing dengan gambar selanjutnya.

Lalu bagaimana solusinya? jawabannya adalah dengan double buffering.

Double Buffering
Jadi begini dengan menggunakan double buffering kita membuat buffer terlebih dahulu kemudian baru ditampilkan ke screen disamping kita menggunakan direct drawing, maksudnya begini kita drawing pada buffer terlebih dahulu baru kemudian kita tampilkan duplikat dari buffer tadi ke screen, sehingga screen hanya akan terupdate sekali. berikut adalah gambar ilustrasinya :

Back buffer adalah sebuah class image juga. Kita bisa menggunakan method createImage(int w, int h) pada class Component untuk membuat back buffer. Sebagai contoh, jika kita ingin membuat double bufferring pada applet yang tidak menggunakan active rendering, kita lakukan override method update() untuk menggunakan double buffer dan memanggil methos paint() dengan konteks berupa double buffer:

private Image doubleBuffer;
...
public void update(Graphics g) {
Dimension size = getSize();
if (doubleBuffer == null ||
doubleBuffer.getWidth(this) != size.width ||
doubleBuffer.getHeight(this) != size.height)
{
doubleBuffer = createImage(size.width, size.height);
}
if (doubleBuffer != null) {
// paint to double buffer
Graphics g2 = doubleBuffer.getGraphics();
paint(g2);
g2.dispose();
// copy double buffer to screen
g.drawImage(doubleBuffer, 0, 0, null);
}
else {
// couldn't create double buffer, just paint to screen
paint(g);
}
}
public void paint(Graphics g) {
// do drawing here
...
}

Page Flipping
Ketika melakukan double buffer akan memakan beberapa saat untuk mengambil gambar dari buffer kemudian ditampilkan ke screen. Resolusi 800x600 dengan kedalaman bit 16 akan memanfaatkan 800x600 bytes, atau 938KB. Nilai tersebut mendekati 1Mb memory yang kemudian diacak untuk 30 frames per second (fps). Akan tetapi melkukan copy terhadap memory tersebut adalah sesuatu yang cukup cepat untuk kebanyakan games, bagaimana jika kita tidak membuat buffer sepenuhnya dan dapatkah secara instans membuat back buffer sebagai display buffer?
Itu dapat dilakukan, teknik seperti ini disebut sebagai page flipping. Dengan page flipping, kita menggunakan 2 buffer, satu untuk back buffer dan satunya lagi untuk display buffer, berikut adalah ilustrasinya:

Display pointer akan menunjuk ke buffer yang sedang ditampilkan. Display pointer ini dapat dirubah pada kebanyakan sistem modern. Ketika kita selesai drawing pada back buffer, display pointer dapat dipindahkan dari display buffer saat ini ke back buffer yang lain, sebagai mana ditunjukkan pada gambar dibawah ini. Ketika pointer berubah, kemudian display buffer akan berubah menjadi back buffer, begitu juga sebaliknya.

Tentu saja, merubah pointer akan sangat lebih cepat dari pada melakukan duplikasi pada sebuah block memory, jadi dengan page flipping akan lebih cepat daripada double buffering.


Monitor Refresh and Tearing

Perlu diingat bahwa monitor mempunyai refresh rate. Refresh rate bisanya antara 75Hz, yang berarti monitor melakukan refreshing sebanyak 75 kali dalam satu detik. Tetapi apa yang terjadi ketika page flipping/double buffer terjadi ditengah2 dari refresh rate? ya, bisa kita tebak sebagian dari buffer yang lama akan ditampilkan bersamaan dengan sebagian dari buffer yang baru. Effek ini sama dengan flickering, sering disebut dengan tearing seperti yang ditunjukkan pada gambar dibaewah ini.

Untuk mengatasi masalah ini kita bisa cukup melakukan page flip tepat sebelum monitor melakukan refresh, kedengarannya sulit untuk dilakukan tetapi jangan khawatir java runtime telah melakukannya untuk kita, dengan menggunakan class BufferStrategy.

BufferStrategy Class
Double buffering, page flipping, dan menunggu refresh monitor semuanya telah ditangani oleh class BufferStrategy. BufferStrategy akan memilih metode buffering yang cocok sesuai dengan kemampuan sistem. Pertama, akan mencoba untuk menerapkan page flipping, jika tidak memungkinkan, akan mencoba dengan double buffering. Juga, menunggu sampai monitor refresh selesai sebelum melakukan page flip. Singkat kata, BufferStrategy akan melakukan hal2 tersebut untuk kita, tanpa kita harus susah payah untuk melakukannya.
Sebagai catatan FPS(Frames Per Second) pada games ditentukan oleh refresh rate yang digunakan, jika monitor menggunakan 75Hz, maka games display maximum hanya mampu menampilkan 75 FPS. Ini berarti kita tidak bisa menggunakan FPS sebagai "BENCHMARK" seberapa cepat sistem berjalan.
Tentu saja, bukanlah suatu masalah ketika suatu games berjalan pada 200 FPS kita akan melihatnya sesuai dengan kemapuan monitor. Tidak masalah secepat apa games tersebut berjalan, kita akan tetap melihatnya dalam 75 FPS pada monitor dengan refresh rate 75Hz.
Canvas dan Window objek keduanya dapat diterapkan BufferStrategy. Gunakan method createBufferStrategy() untuk membuat BufferStrategy sesuai dengan jumlah buffer yang kita inginkan. Kita akan membutuhkan kurang lebih 2 buffer untuk double buffering dan page flipping. Sebaga contoh:
frame.createBufferStrategy(2);

Setelah membuat BufferStrategy, kita bisa panggil method getBufferStrategy() untuk mereferensi ke buffer yang telah dibuat dan gunakan method getDrawGraphics() untuk mendapatkan graphic konteks untuk drawing buffer. Setelah selesai drawing, kita bisa panggil method show() untuk menampilkan graphic di buffer, berikut contohnya:
BufferStrategy strategy = frame.getBufferStrategy();
Graphics g = strategy.getDrawGraphics();
draw(g);
g.dispose();
strategy.show();

Membuat Screen Manager
Oke sekarang kita akan update SimpleScreenManager pada tulisan sebelumnya dengan feature yang baru. Berikut ini yang akan ditambahkan:
  • Double buffering dan page flipping dengan membuat BufferStrategy

  • getGraphics(), yang akan mendapatkan graphic konteks untuk ditampilkan

  • update(), yang akan melakukan update ke display

  • getCompatibleDisplayModes(), yang akan mendapatkan list dari display mode

  • getCurrentDispalayMode(), yang akan mendapatkan display mode saat ini

  • findFirstCompatibleMode(), yang akan mendapatkan compatible mode yang pertama yang berada di list mode


Sekarang kita akan melakukan active rendering, tidak membutuhkan JFrame untuk full-screen window untuk menerima paint event dari sistem operasi, jadi kita bisa matikan saja dengan:
frame.ignoreRepaint(true);

Ini tidak akan mematikan normal repaint event. Memangil repaint() pada JFrame masih akan tetap bisa. Oke sekarang kita update SimpleScreenManager menjadi sebagai berikut:
package brain.left.games;

import java.awt.*;
import java.awt.image.BufferStrategy;
import java.awt.image.BufferedImage;

import javax.swing.JFrame;

/**
* The ScreenManager class manages initializing and displaying full screen
* graphics modes.
*/
public class ScreenManager {
private GraphicsDevice device;

/**
* Creates a new ScreenManager object.
*/
public ScreenManager() {
GraphicsEnvironment environment = GraphicsEnvironment
.getLocalGraphicsEnvironment();
device = environment.getDefaultScreenDevice();
}

/**
* Returns a list of compatible display modes for the default device on the
* system.
*/
public DisplayMode[] getCompatibleDisplayModes() {
return device.getDisplayModes();
}

/**
* Returns the first compatible mode in a list of modes. Returns null if no
* modes are compatible.
*/
public DisplayMode findFirstCompatibleMode(DisplayMode modes[]) {
DisplayMode goodModes[] = device.getDisplayModes();
for (int i = 0; i < modes.length; i++) {
for (int j = 0; j < goodModes.length; j++) {
if (displayModesMatch(modes[i], goodModes[j])) {
return modes[i];
}
}
}
return null;
}

/**
* Returns the current display mode.
*/
public DisplayMode getCurrentDisplayMode() {
return device.getDisplayMode();
}

/**
* Determines if two display modes "match". Two display modes match if they
* have the same resolution, bit depth, and refresh rate. The bit depth is
* ignored if one of the modes has a bit depth of
* DisplayMode.BIT_DEPTH_MULTI. Likewise, the refresh rate is ignored if one
* of the modes has a refresh rate of DisplayMode.REFRESH_RATE_UNKNOWN.
*/
public boolean displayModesMatch(DisplayMode mode1, DisplayMode mode2) {
if (mode1.getWidth() != mode2.getWidth()
|| mode1.getHeight() != mode2.getHeight()) {
return false;
}
if (mode1.getBitDepth() != DisplayMode.BIT_DEPTH_MULTI
&& mode2.getBitDepth() != DisplayMode.BIT_DEPTH_MULTI
&& mode1.getBitDepth() != mode2.getBitDepth()) {
return false;
}
if (mode1.getRefreshRate() != DisplayMode.REFRESH_RATE_UNKNOWN
&& mode2.getRefreshRate() != DisplayMode.REFRESH_RATE_UNKNOWN
&& mode1.getRefreshRate() != mode2.getRefreshRate()) {
return false;
}
return true;
}

/**
* Enters full screen mode and changes the display mode. If the specified
* display mode is null or not compatible with this device, or if the
* display mode cannot be changed on this system, the current display mode
* is used.
*


* The display uses a BufferStrategy with 2 buffers.
*/
public void setFullScreen(DisplayMode displayMode) {
JFrame frame = new JFrame();
frame.setUndecorated(true);
frame.setIgnoreRepaint(true);
frame.setResizable(false);
device.setFullScreenWindow(frame);
if (displayMode != null && device.isDisplayChangeSupported()) {
try {
device.setDisplayMode(displayMode);
} catch (IllegalArgumentException ex) {
}
}
frame.createBufferStrategy(2);
}

/**
* Gets the graphics context for the display. The ScreenManager uses double
* buffering, so applications must call update() to show any graphics drawn.
*


* The application must dispose of the graphics object.
*/
public Graphics2D getGraphics() {
Window window = device.getFullScreenWindow();
if (window != null) {
BufferStrategy strategy = window.getBufferStrategy();
return (Graphics2D) strategy.getDrawGraphics();
} else {
return null;
}
}

/**
* Updates the display.
*/
public void update() {
Window window = device.getFullScreenWindow();
if (window != null) {
BufferStrategy strategy = window.getBufferStrategy();
if (!strategy.contentsLost()) {
strategy.show();
}
}
// Sync the display on some systems.
// (on Linux, this fixes event queue problems)
Toolkit.getDefaultToolkit().sync();
}

/**
* Returns the window currently used in full screen mode. Returns null if
* the device is not in full screen mode.
*/
public Window getFullScreenWindow() {
return device.getFullScreenWindow();
}

/**
* Returns the width of the window currently used in full screen mode.
* Returns 0 if the device is not in full screen mode.
*/
public int getWidth() {
Window window = device.getFullScreenWindow();
if (window != null) {
return window.getWidth();
} else {
return 0;
}
}

/**
* Returns the height of the window currently used in full screen mode.
* Returns 0 if the device is not in full screen mode.
*/
public int getHeight() {
Window window = device.getFullScreenWindow();
if (window != null) {
return window.getHeight();
} else {
return 0;
}
}

/**
* Restores the screen's display mode.
*/
public void restoreScreen() {
Window window = device.getFullScreenWindow();
if (window != null) {
window.dispose();
}
device.setFullScreenWindow(null);
}

/**
* Creates an image compatible with the current display.
*/
public BufferedImage createCompatibleImage(int w, int h, int transparency) {
Window window = device.getFullScreenWindow();
if (window != null) {
GraphicsConfiguration gc = window.getGraphicsConfiguration();
return gc.createCompatibleImage(w, h, transparency);
}
return null;
}
}


Pada ScreenManager, kita berikan catatan tersendiri untuk line berikut pada methode update():
Toolkit.getDefaultToolkit().sync();

Baris diatas akan memastikan bahwa display telah disinkronisasi dengan sistem window. Pada kebanyakan sistem, methos ini tidak berpengaruh, tetapi pada linux, memanggil method ini akan memperbaiki AWT event queue. Tanpa memanggil method ini, pada beberapa sistem linux akan menyebabkan terjadinya delay pada mouse dan keyboard event.

Dua method baru yang perlu digaris bawahi pada class ScreenManager adalah displayModesMatch() dam createCompatibleImage().
Method displayModesMatch() akan mengecek antara dua DisplayMode objek sama, yaitu dari sisi resolusi, kedalaman bit, dan refresh rate. Kedalaman bit dan refresh rate akan diabaikan jika jika ada salah satu saja yang tidak sama.
Method createCompatibleImage() akan membuat image yang sesuai dengan display, image akan mempunyai kedalaman bit yang sama dan model warna yang sama dengan display. Kita memanfaatkan BufferedImage karena method ini berguna untuk membuat image transparent atau translucent karena method createImage() hanya akan membuat opaque image.

Sekarang kita akan mengupdate AnimationTest1 pada tulisan sebelumnya menjadi AnimationTest2 dengan menggunakan ScreenManager yang telah kita tingkatkan kemampunannya, listing kodenya adalah sebagai berikut: HOOOREEEE ga ada flickering lagi!!
package brain.left.games;

import java.awt.*;
import javax.swing.ImageIcon;

public class AnimationTest2 {
public static void main(String args[]) {
AnimationTest2 test = new AnimationTest2();
test.run();
}

private static final DisplayMode POSSIBLE_MODES[] = {
new DisplayMode(800, 600, 32, 0), new DisplayMode(800, 600, 24, 0),
new DisplayMode(800, 600, 16, 0), new DisplayMode(640, 480, 32, 0),
new DisplayMode(640, 480, 24, 0), new DisplayMode(640, 480, 16, 0) };
private static final long DEMO_TIME = 10000;
private ScreenManager screen;
private Image bgImage;
private Animation anim;

public void loadImages() {
// load images
bgImage = loadImage("images/tile-games/background.jpg");
Image player1 = loadImage("images/tile-games/hero1.png");
Image player2 = loadImage("images/tile-games/hero2.png");
Image player3 = loadImage("images/tile-games/hero3.png");
// create animation
anim = new Animation();
anim.addFrame(player1, 250);
anim.addFrame(player2, 150);
anim.addFrame(player1, 150);
anim.addFrame(player2, 150);
anim.addFrame(player3, 200);
anim.addFrame(player2, 150);
}

private Image loadImage(String fileName) {
return new ImageIcon(fileName).getImage();
}

public void run() {
screen = new ScreenManager();
try {
DisplayMode displayMode = screen
.findFirstCompatibleMode(POSSIBLE_MODES);
screen.setFullScreen(displayMode);
loadImages();
animationLoop();
} finally {
screen.restoreScreen();
}
}

public void animationLoop() {
long startTime = System.currentTimeMillis();
long currTime = startTime;
while (currTime - startTime < DEMO_TIME) {
long elapsedTime = System.currentTimeMillis() - currTime;
currTime += elapsedTime;
// update animation
anim.update(elapsedTime);
// draw and update screen
Graphics2D g = screen.getGraphics();
draw(g);
g.dispose();
screen.update();
// take a nap
try {
Thread.sleep(20);
} catch (InterruptedException ex) {
}
}
}

public void draw(Graphics g) {
// draw background
g.drawImage(bgImage, 0, 0, null);
// draw image
g.drawImage(anim.getImage(), 0, 0, null);
}
}

Tidak banyak yang berubah dari kode sebelumnya. Satu hal yang berubah adalah cara AnimationTest2 dalam memilih display mode. AnimationTest2 menggunakan ScreenManager untuk mendapatkan list display mode yang compatible, dan ScreenManager memilih list pertama.
ScreenManager membuat JFrame sendiri, jadi AnimationTeast2 tidak ada hubungannya dengan pembuatan JFrame untuk full-screen mode.

Sprites
Animation sekarang berjalan dengan smooth, tetapi tidak menarik jika kita membuat animasi hanya ditempat. Sekarang kita akan membuat sprite.
Sprite adalah graphic yang bergerak secara independen pada screen. Pada kasus ini, sprite akan dianimasikan dan bergerak pada suatu waktu.
Berdasarkan sebuah animasi, sprite akan disusun dari dua hal: posisi dan velocity. Jika kita mau mengingat kembali tentang velocity sewaktu kita disekolah, velocity adalah kecepatan(misal 22mph) dan arah(misal utara). Pada kasus ini, kita akan membagi percepatan kedalam dua bagian yaitu komponen horizontal dan vertical. Kita tidak menggunakan miles per jam atau meters per detik, tetapi kita akan menggunakan pixel per milidetik.
Kita mungkin bertanya-tanya, "Mengapa kita menggunakan velocity? Mengapa tidak hanya update posisi sprite pada beberapa frame?" Oke, jika ada pertanyaan seperti itu, sprite akan bergerak pada kecepatan yang berbeda tergantung dari kecepatan mesin. Lebih besar frame rate berarti kecepatan sprite akan lebih cepat juga. Sprite akan bergerak berubah secara real time karena sprite bergerak dengan pace yang konsisten, apakah waktu antara frames pendek atau panjang.
Sprite class pada listing program dibawah ini akan dianimasikan posisi dan velocity-nya.
Kita dapat mendefinisikan posisi sprite dalam integer, tetapi bagaimana jika sprite bergerak lambat? Sebagai contoh, bayangkan sebuah sprite bergerak pada pixel kesepuluh setiap kali diupdate. Ini berarti bahwa akan ada 9 gerakan tak terlihat dari 10 gerakan saat diupdate. Jika posisi sprite dalam integer, sprite tidak akan pernah bergerak karena hasilnya tidak akan dibulatkan setiap waktu.
Jika posisi sprite dalam floating point, posisi sprite dapat mengincrement pergerakan yang tidak terlihat, dan sprite akan bergerak 1 pixel pada setiap memanggil update() kesepuluh. Berikut adalah listing program Sprite.java
package brain.left.games;

import java.awt.Image;

public class Sprite {
private Animation anim;
// position (pixels)
private float x;
private float y;
// velocity (pixels per millisecond)
private float dx;
private float dy;

/**
* Creates a new Sprite object with the specified Animation.
*/
public Sprite(Animation anim) {
this.anim = anim;
}

/**
* Updates this Sprite's Animation and its position based on the velocity.
*/
public void update(long elapsedTime) {
x += dx * elapsedTime;
y += dy * elapsedTime;
anim.update(elapsedTime);
}

/**
* Gets this Sprite's current x position.
*/
public float getX() {
return x;
}

/**
* Gets this Sprite's current y position.
*/
public float getY() {
return y;
}

/**
* Sets this Sprite's current x position.
*/
public void setX(float x) {
this.x = x;
}

/**
* Sets this Sprite's current y position.
*/
public void setY(float y) {
this.y = y;
}

/**
* Gets this Sprite's width, based on the size of the current image.
*/
public int getWidth() {
return anim.getImage().getWidth(null);
}

/**
* Gets this Sprite's height, based on the size of the current image.
*/
public int getHeight() {
return anim.getImage().getHeight(null);
}

/**
* Gets the horizontal velocity of this Sprite in pixels per millisecond.
*/
public float getVelocityX() {
return dx;
}

/**
* Gets the vertical velocity of this Sprite in pixels per millisecond.
*/
public float getVelocityY() {
return dy;
}

/**
* Sets the horizontal velocity of this Sprite in pixels per millisecond.
*/
public void setVelocityX(float dx) {
this.dx = dx;
}

/**
* Sets the vertical velocity of this Sprite in pixels per millisecond.
*/
public void setVelocityY(float dy) {
this.dy = dy;
}

/**
* Gets this Sprite's current image.
*/
public Image getImage() {
return anim.getImage();
}
}

Class sprite sangatlah simpel. Kebanyakan terdiri dari get dan set method. Semuanya akan diakhiri dengan method update(), yang akan mengupdate posisi dari sprite berdasarkan velocity dan banyaknya waktu yang tersedia.
Sekarang kita bersenang-senang dengan menggunakan class Sprite yang telah kita buat sebelumnya, kita akan membuat karakter memantul-mantul pada screen, berikut adalah listing program SpriteTest1 :
package brain.left.games;

import java.awt.*;
import javax.swing.ImageIcon;

public class SpriteTest1 {
public static void main(String args[]) {
SpriteTest1 test = new SpriteTest1();
test.run();
}

private static final DisplayMode POSSIBLE_MODES[] = {
new DisplayMode(800, 600, 32, 0), new DisplayMode(800, 600, 24, 0),
new DisplayMode(800, 600, 16, 0), new DisplayMode(640, 480, 32, 0),
new DisplayMode(640, 480, 24, 0), new DisplayMode(640, 480, 16, 0) };
private static final long DEMO_TIME = 10000;
private ScreenManager screen;
private Image bgImage;
private Sprite sprite;

public void loadImages() {
// load images
bgImage = loadImage("images/tile-games/background.jpg");
Image player1 = loadImage("images/tile-games/hero1.png");
Image player2 = loadImage("images/tile-games/hero2.png");
Image player3 = loadImage("images/tile-games/hero3.png");
// create sprite
Animation anim = new Animation();
anim.addFrame(player1, 250);
anim.addFrame(player2, 150);
anim.addFrame(player1, 150);
anim.addFrame(player2, 150);
anim.addFrame(player3, 200);
anim.addFrame(player2, 150);
sprite = new Sprite(anim);
// start the sprite off moving down and to the right
sprite.setVelocityX(0.2f);
sprite.setVelocityY(0.2f);
}

private Image loadImage(String fileName) {
return new ImageIcon(fileName).getImage();
}

public void run() {
screen = new ScreenManager();
try {
DisplayMode displayMode = screen
.findFirstCompatibleMode(POSSIBLE_MODES);
screen.setFullScreen(displayMode);
loadImages();
animationLoop();
} finally {
screen.restoreScreen();
}
}

public void animationLoop() {
long startTime = System.currentTimeMillis();
long currTime = startTime;
while (currTime - startTime < DEMO_TIME) {
long elapsedTime = System.currentTimeMillis() - currTime;
currTime += elapsedTime;
// update the sprites
update(elapsedTime);
// draw and update the screen
Graphics2D g = screen.getGraphics();
draw(g);
g.dispose();
screen.update();
// take a nap
try {
Thread.sleep(20);
} catch (InterruptedException ex) {
}
}
}

public void update(long elapsedTime) {
// check sprite bounds
if (sprite.getX() < 0) {
sprite.setVelocityX(Math.abs(sprite.getVelocityX()));
} else if (sprite.getX() + sprite.getWidth() >= screen.getWidth()) {
sprite.setVelocityX(-Math.abs(sprite.getVelocityX()));
}
if (sprite.getY() < 0) {
sprite.setVelocityY(Math.abs(sprite.getVelocityY()));
} else if (sprite.getY() + sprite.getHeight() >= screen.getHeight()) {
sprite.setVelocityY(-Math.abs(sprite.getVelocityY()));
}
// update sprite
sprite.update(elapsedTime);
}

public void draw(Graphics g) {
// draw background
g.drawImage(bgImage, 0, 0, null);
// draw sprite
g.drawImage(sprite.getImage(), Math.round(sprite.getX()), Math
.round(sprite.getY()), null);
}
}

Karena Sprite objek mengontrol gerakannya sendiri, pada kelas SpriteTest1 tidak akan berubah banyak dibandingkan dengan AnimationTest2. Hal yang beru adalah method update(), yang menyebabkan sprite memantul ketika membentur batas screen. Jika sprite membentur batas kiri atau kanan, horizontal velocity akan dibalik. Jika sprite membentur batas atas atau bawah vertical velocity akan dibalik.

3 comments:

  1. ada cara lain ga selain double buffering sama page flipping ?

    ReplyDelete
  2. @4k4n3ch4n
    sama2

    @RC
    belum ada, sepertinya yang sering dipake yang double buffering.
    tapi kalo pengen enak pake framework ajah bufferingnya dah ditangani sama frameworknya.

    ReplyDelete