zondag 14 oktober 2007

Effecten in swing


Tijdens mijn ziekte vorige week heb ik eens uitgeprobeerd hoever ik nou kan gaan in met Java Swing custom painting en geprobeerd om een effect na te bootsten wat ik ooit in assembler op school had geschreven. Dit effect bootst vuur na door middel van een vrij eenvoudig algoritme. Later meer hierover.

Om dit te bouwen heb ik een abstracte class gedefinieerd die de java.awt.Paint interface implementeert, zodat op eenvoudige wijze het scherm gevuld kan worden dmv setPaint op het Graphics object en een fillRect call.
Ik ga niet elke class bespreken hoe deze werkt, dit kun je me, als je het niet volgt, natuurlijk altijd vragen :).

De Effect class ziet er als volgt uit:





public abstract class Effect implements java.awt.Paint {

private final EffectListener listener;
protected DataBuffer db;
protected SampleModel model;

Timer effectTimer = new Timer(30, new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
buildNextFrame();
listener.nextFrameReady();
}
} );

protected abstract void buildNextFrame();
protected abstract SampleModel createModel();
protected abstract DataBuffer createBuffer();

public DataBuffer getBuffer() {
if (this.db == null) {
this.db = createBuffer();
}
return this.db;
}

public SampleModel getModel() {
if (this.model == null) {
this.model = createModel();
}
return this.model;
}

public Effect(EffectListener l) {
if (l == null) throw new IllegalArgumentException("l == null");
this.listener = l;
this.effectTimer.start();
}

public interface EffectListener {
void nextFrameReady();
}

@Override
public abstract PaintContext createContext(ColorModel cm,
Rectangle deviceBounds,
Rectangle2D userBounds,
AffineTransform xform,
final RenderingHints hints);


@Override
public int getTransparency() {
return Transparency.OPAQUE;
}

}




Naast de definitie van het effect is er ook een Swingcomponent nodig dat dit effect laat zien, deze ziet er als volgt uit:




public class EffectPanel extends JPanel {

private Effect effect;

public EffectPanel() {
super.setOpaque(true);
}

@Override
protected void paintComponent(Graphics g) {
if (this.effect == null) return;
((Graphics2D) g).setPaint(this.effect);
g.fillRect(0, 0, getWidth(), getHeight());
}

public Effect getEffect() {
return this.effect;
}

public void setEffect(Effect effect) {
this.effect = effect;
}

}




Dit is alles wat we nodig hebben om een leuk effect op te kunnen bouwen in swing.
Het geheel is 100% compatible met swing en dit zou direct in elke applicatie geplugged kunnen worden door een bestaand JPanel te vervangen door het EffectPanel.
Aan het effectPanel kunnen gewoon op normale wijze andere Swingcomponenten worden toegevoegd layoutManagers geset worden etc.


Dit frameWork zou dus als volgt gebruikt kunnen worden, belangrijk is dat er aan het effect een listener wordt gekoppeld die het scherm opnieuw tekent als er een nieuw frame beschikbaar is. In dit geval heeft de effect-class een static methode die de "dirty region" bepaald.



public class EffectPanelTest {

public static void main(String[] args) {
SwingUtilities.invokeLater(new Runnable() {
public void run() {
final EffectPanel p = new EffectPanel();
p.setEffect(new FireEffect(new Effect.EffectListener() {
@Override
public void nextFrameReady() {
p.repaint(FireEffect.getRepaintRectangle(p));
}
} ));
JFrame f = new JFrame();
f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
f.setSize(340, 200);
f.setContentPane(p);
p.setLayout(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.anchor = c.NORTH;
p.add(new JButton("North button"), c);
c.anchor = c.SOUTH;
c.weighty = 1;
c.gridy = 2;
p.add(new JButton("South button"), c);
f.setVisible(true);
};
} );
}

}


Meer over het algoritme

Het originele algoritme werkte als volgt: vul de onderste 2 regels van de schermbuffer met willekeurige pixelswaarden tussen 0 en 255. Loop daarna de hele buffer af en tel de pixelwaarde onder, links van en rechts van op bij de huidige waarde van de pixel en deel dit door 4, trek er één af en plaats deze 1 boven de huidige positie terug in de buffer. Kopieer daarna de buffer naar het scherm en herhaal bovenstaande stappen.

Dit werkte toen goed omdat er gebruik werd gemaakt van 256 kleuren en een pallet aangepast op de kleuren. Dit zullen we dus moeten emuleren. Een ander verschil met toen is de resolutie van het scherm, nu op de pc waarop dit ontwikkeld is 1680x1050 toen 320x200. Dit verschil wordt nagebootst door een buffer met een vaste grootte te gebruiken en deze te schalen naar de benodigde grootte.




public class FireEffect extends Effect {

public FireEffect(EffectListener l) {
super(l);
initColors();
initBuffer();
}

Color colors[] = new Color[255];

public final static int height = 100;
public final static int width = 800;

Random r = new Random();

private void initBuffer() {
for (int x = 0; x < width; x++) {
for (int y = 0; y < height - 3; y++) {
getModel().setPixel(x, y, new int[] {0, 0, 0}, getBuffer());
}
}
}
/**
* initializeert het geemuleerde pallet
*/
private void initColors() {
for (int i = 0; i < 255; i++) {
int r = i - i / 16;
int g = i / 3;
int b = i / 8;
colors[i] = new Color(r, g, b);
}
}
int pixelBuffer[][] = new int[width][height];

@Override
protected SampleModel createModel() {
return new SinglePixelPackedSampleModel(DataBuffer.TYPE_INT, width, height, new int[] {0xFF0000, 0x00FF00, 0x0000FF});
}

@Override
protected DataBuffer createBuffer() {
return new DataBufferInt(width * height);
}

@Override
public PaintContext createContext(ColorModel cm, Rectangle deviceBounds,
final Rectangle2D userBounds, AffineTransform xform, final RenderingHints hints) {
PaintContext p = new PaintContext() {
@Override
public Raster getRaster(int x, int y, int w, int h) {
WritableRaster result = Raster.createWritableRaster(getModel(), getBuffer(), new Point(x, y));
for (int x1 = x; x1 < x + w; x1 ++) {
for (int y1 = y; y1 < y + h; y1 ++) {
// kopieer de waarden uit het geemuleerde pallet en schaal naar de grootte van userBounds
double userX = ((double) x1 / (double) userBounds.getBounds().width) * (double) width;
double userY = ((double) y1 / (double) userBounds.getBounds().height) * (double) (height - 3);
Color col = colors[pixelBuffer[(int) userX][(int) userY]];
result.setPixel(x1, y1 , new int[] {col.getRed(), col.getGreen(), col.getBlue()});
}
}
return result;
}
@Override
public void dispose() {
// doe hier niets, normaal vind hier cleanup plaats dit hoeft niet in deze demo
}
@Override
public ColorModel getColorModel() {
return new DirectColorModel(32, 0xFF0000, 0x00FF00, 0x0000FF);
}
} ;
return p;
}

@Override
public void buildNextFrame() {
for (int x = 1; x < width - 1; x++) {
for (int y = height - 2; y > height - 65 ; y--) {
int pixel1 = pixelBuffer[x - 1][y];
int pixel2 = pixelBuffer[x + 1][y];
int pixel3 = pixelBuffer[x][y];
int pixel4 = pixelBuffer[x][y - 1];
int newCol = (pixel1 + pixel2 + pixel3 + pixel4) / 4;
newCol = newCol < 2 ? 0 : newCol - 2;
Color c = colors[newCol];
pixelBuffer[x][y - 1] = newCol;
}
}
for (int x = 0; x < width; x+=4) {
for (int y = height - 3; y < height - 2; y++) {
int val = r.nextInt(255);
pixelBuffer[x][y] = val;
pixelBuffer[x + 1][y + 2] = val;
pixelBuffer[x + 2][y + 2] = val;
pixelBuffer[x + 3][y + 1] = val;
pixelBuffer[x + 2][y + 2] = val;
pixelBuffer[x + 1][y + 1] = val;
pixelBuffer[x + 2][y + 1] = val;
}
}

}

/**
* Geeft de dirty region terug zodat er niet te veel gerepaint wordt
* @param p
* @return
*/
public static Rectangle getRepaintRectangle(JComponent p) {
Rectangle repaint = new Rectangle();
double h = ((double) 65 / (double) FireEffect.height) * (double) p.getHeight();
repaint.y = p.getHeight() - (int) h;
repaint.x = 0;
repaint.height = (int) h;
repaint.width = p.getWidth();
return repaint;
}

}




Het belangrijkste in dit geval is natuurlijk niet de sourcecode, maak om het ding in het echt te zien.
Webstart demo: