Week 3

2D Computer graphics using Java2D

cc

This material is licensed under the Creative Commons BY-NC-SA license, which means that you can use it and distribute it freely so long as you do not erase the names of the original authors. If you do changes in the material and want to distribute this altered version of the material, you have to license it with a similar free license. The use of the material for commercial use is prohibited without a separate agreement.

Authors: Johan Talboom, Etiënne Goossens

The course is maintained by Technische Informatica Breda

Animeren met timers

Animatie in computer graphics wordt gedaan door het scherm steeds opnieuw te tekenen, waarbij objecten steeds een klein beetje veranderen. Door de X-coordinaat van een object op te hogen krijg je een beweging naar rechts. We kunnen dit proces opdelen in twee, van elkaar losstaande delen code; het updaten en het tekenen. Door nu intern variabelen op te slaan met de state van de applicatie, deze aan te passen in de update, en te gebruiken in de draw kunnen we nu animeren. Voor het steeds opnieuw aanroepen van code gebruiken we de AnimationTimer klasse. Deze kunnen we via de volgende constructie aanmaken:

public class HelloAnimation extends Application {

    private Stage stage;
    private double angle = 0.0;


    @Override
    public void start(Stage stage) throws Exception {
        this.stage = stage;
        javafx.scene.canvas.Canvas canvas = new Canvas(1920, 1080);
        FXGraphics2D g2d = new FXGraphics2D(canvas.getGraphicsContext2D());
        draw(g2d);
        stage.setScene(new Scene(new Group(canvas)));
        stage.setTitle("Hello Animation");
        primaryStage.show();

        new AnimationTimer() {
            long last = -1;
            @Override
            public void handle(long now) {
                if(last == -1)
                    last = now;
                update((now - last) / 1000000000.0);
                last = now;
                draw(g2d);
            }
        }.start();
    }

    private void update(double deltaTime) {
        angle+=0.1;
    }

    public void draw(FXGraphics2D g2d) {
        g2d.setBackground(Color.white);
        g2d.clearRect(0,0,1920,1080);
        AffineTransform tx = new AffineTransform();
        tx.translate(1920/2, 1080/2);
        tx.rotate(angle);
        tx.translate(200, 0);
        g2d.fill(tx.createTransformedShape(new Rectangle2D.Double(-50,-50,100,100)));
    }
}

De AnimationTimer kan aangemaakt worden in de start methode. De implementatie van deze methode bevat alles wat nodig is om alle objecten te laten bewegen en animeren. De timer zal dus steeds twee methodes aanroepen, namelijk de update en draw methode. De draw method wordt ook aangeroepen wanneer je bijvoorbeeld het scherm schaalt. De update methode zorgt ervoor dat de state van alle objecten word aangepast, afhankelijk van de update snelheid. De draw methode is alleen verantwoordelijk voor het tekenen van alle objecten. Het is belangrijk dat de update methode niet te lang duurt!

Let op: de volgende code gaat dus niet werken:

public class HelloAnimation extends Application {

    private Stage stage;
    private double angle = 0;
    @Override
    public void start(Stage primaryStage) throws Exception {
        stage = primaryStage;
        Canvas canvas = new Canvas(1920, 1080);
        FXGraphics2D g2d = new FXGraphics2D(canvas.getGraphicsContext2D());
        draw(g2d);
        primaryStage.setScene(new Scene(new Group(canvas)));
        primaryStage.setTitle("Hello Animation");
        primaryStage.show();

        new AnimationTimer() {
            long last = -1;
            @Override
            public void handle(long now) {
                if(last == -1)
                    last = now;
                update((now - last) / 1000000000.0);
                last = now;
                draw(g2d);
            }
        }.start();
    }

    public void draw(FXGraphics2D g2d) {
        AffineTransform tx = new AffineTransform();
        tx.translate(getWidth()/2, getHeight()/2);
        tx.rotate(angle);
        tx.translate(200, 0);

        g2d.fill(tx.createTransformedShape(new Rectangle2D.Double(-50,-50,100,100)));
    }

    private void update(double deltaTime) {
        while(true) {
            angle+=0.1;
        }
    }
}

We hebben nu dus 2 losse methoden om te implementeren, een draw en een update methode. Om je programma overzichtelijk te houden, is het belangrijk om deze 2 methoden hun eigen doel te geven. De draw methode tekent alleen, en past geen variabelen aan. De update methode tekent niets op ‘t scherm, maar veranderd alleen variabelen. Om tussen deze methoden te communiceren, zul je dus gebruik moeten maken van attributen om te communiceren tussen deze 2 methoden, en om de state van je applicatie op te slaan. Het is verstandig deze state op te splitsen in klassen, indien nodig. Stel dat je een applicatie wilt maken met een bewegende bal. Je kunt hiervoor een bal-klasse introduceren, waar ook de teken en update code gesplitst zijn

public class Ball
{
    private Point2D position;
    private Point2D speed;

    public Ball(Point2D position)
    {
        this.position = position;
        this.speed = new Point2D.Double(Math.random()*10-5, Math.random()*10-5);
    }

    public void update()
    {
        this.position = new Point2D.Double(this.position.getX() + this.speed.getX(), this.position.getY() + this.speed.getY());
    }

    public void draw(FXGraphics2D graphics)
    {
        graphics.draw(new Ellipse2D.Double(this.position.getX()-5, this.position.getY()-5, 10, 10));
    }
}

Hierna kunnen we deze bal gebruiken:


public class HelloAnimation extends Application {
    private Ball ball;

    @Override
    public void start(Stage stage) throws Exception {
        init();
        javafx.scene.canvas.Canvas canvas = new Canvas(1920, 1080);
        FXGraphics2D g2d = new FXGraphics2D(canvas.getGraphicsContext2D());
        stage.setScene(new Scene(new Group(canvas)));
        stage.setTitle("Hello Animation");
        primaryStage.show();

        new AnimationTimer() {
            long last = -1;
            @Override
            public void handle(long now) {
                if(last == -1)
                    last = now;
                update((now - last) / 1000000000.0);
                last = now;
                draw(g2d);
            }
        }.start();
    }
    private void init()
    {
        ball = new Ball(new Point2D.Double(100,100));
    }

    private void update(double deltaTime) {
        ball.update();
    }

    public void draw(FXGraphics2D g2d) {
        g2d.setBackground(Color.white);
        g2d.clearRect(0,0,1920,1080);
        ball.draw(g2d);
    }
}

Deze bal klasse kunnen we ook herbruiken, door ze bijvoorbeeld in een ArrayList op te slaan. Daarnaast zouden we hier ook een generiek gameobject van kunnen maken, waarbij verschillende objecten in een game deze klasse extenden om eenzelfde gedrag te vertonen


Tekst

We kunnen tekst op ‘t scherm zetten met de drawString(String text, float x, float y) methode. Deze methode zal een tekst tekenen op de aangegeven positie, met de transformatie die op dat moment in het Graphics2D object staat opgeslagen. Daarnaast kunnen we een lettertype instellen met de setFont(Font font) methode. In dit font object staan de eigenschappen van het lettertype opgeslagen, zoals welk lettertype dit is, de afmetingen en de styling (bold, italic).

Omdat de geïnstalleerde lettertypen op iedere computer anders zijn, heeft java een aantal standaard lettertypen gedefinieerd:

monospace

De standaard lettertypen die gebruikt worden zijn Proportional. Dit betekent dat alle letters een andere breedte hebben, en ook een eigen ruimte hebben tussen een letter en opvolgende letters. Dit leest prettiger voor gewone teksten. Voor sommige zaken, zoals code, is het prettiger een MonoSpaced font te gebruiken. In deze lettertypen zijn alle letters even breed, en hebben altijd dezelfde tussenruimte (tussen het begin van de letters). Hierdoor lijnt tekst beter uit.

serif

Serifs (Nederlands: Schreef), zijn de uitstekende stukjes in een lettertype. Door gebruik te maken van een serif, krijgt een lettertype meer variatie, waardoor het gemakkelijker te herkennen is. Lettertypen met serif worden vooral gebruikt bij gedrukte tekst. Op een beeldscherm wordt meestal gebruik gemaakt van lettertypen zonder serif, omdat er bij de omzetting naar pixels maar een beperkt aantal pixels beschikbaar zijn, wat er voor zorgt dat de serif vaak erg klein zijn en afleiden van de rest van de letter.

Om een font te maken kan gebruik worden gemaakt van de Font(String name, int style, int size) constructor. De naam kan de naam van een lettertype op de PC zijn, maar kan ook een van de standaard java-lettertypen zijn (Serif, SansSerif, Monospaced, Dialog, Dialoginput). Deze namen staan ook opgeslagen in de Font-klasse, als Font.DIALOG en Font.MONOSPACED etc. De style geeft de stijl van het lettertype aan:

Daarnaast is het mogelijk om direct een TrueType font in te laden uit een bestand (dat je bijvoorbeeld mee kunt leveren met je applicatie), met de statische createFont methode:

Font font = Font.createFont(Font.TRUETYPE_FONT, new File("A.ttf"));

Daarnaast is het mogelijk om een lettertype af te leiden van een bestaand, ingeladen lettertype. Dit kan gedaan worden door gebruik te maken van de Font.deriveFont() methoden. Met de verschillende deriveFont methoden kunnen de afmetingen, stijl of zelfs een complete transformatie op een font veranderd worden.

Om nu meer te kunnen doen met fonts, kunnen we deze ook omzetten naar een Shape, zodat we deze kunnen gebruiken met de fill en draw methoden. Iedere tekst zal dan dus wel een eigen Shape-object krijgen, dus dit is niet erg handig in gebruik van dynamische teksten. Met de volgende code kunnen we bijvoorbeeld een outline tekenen:

public void draw(FXGraphics2D g2d) {
    Font font = new Font("Arial", Font.PLAIN, 30);
    Shape shape = font.createGlyphVector(g2d.getFontRenderContext(), "Hello World").getOutline();
    g2d.draw(shape);
    g2d.draw(AffineTransform.getTranslateInstance(100,100).createTransformedShape(shape));
}

Door nu voor iedere letter een shape te maken, kunnen nog geavanceerdere teksteffecten bereikt worden, zoals het individueel verkleuren van letters tot het tekenen van tekst in een boog

Opgave 3-1. Regenboog

Maak een applicatie die de tekst ‘regenboog’, in de vorm van een regenboog, in regenboogkleuren tekent. Je hoeft hiervoor niet de coördinaten van de letters te berekenen, maar dit kun je doen door middel van AffineTransform transformaties (eerst roteren, dan omhoog zetten)

regenboog


Afbeeldingen

De Image klasse, is een abstracte klasse om met afbeeldingen te werken. Deze kun je echter niet zomaar aanmaken, omdat deze abstract is. Afbeelding op 2 manieren aangemaakt worden, uit een bestand inladen of een nieuwe lege afbeelding maken. Om een afbeelding in te laden vanuit een bestand en te tekenen, kunnen we de volgende code gebruiken:

class HelloImage extends Application {

    private Stage stage;
    private BufferedImage image;

    @Override
    public void start(Stage stage) throws Exception {
        this.stage = stage;
        Canvas canvas = new Canvas(1920, 1080);

        try {
            image = ImageIO.read(getClass().getResource("/images/test.png"));
        } catch (IOException e) {
            e.printStackTrace();
        }


        FXGraphics2D g2d = new FXGraphics2D(canvas.getGraphicsContext2D());
        draw(g2d);
        this.stage.setScene(new Scene(new Group(canvas)));
        this.stage.setTitle("Hello images");
        this.stage.show();
    }

    public void draw(FXGraphics2D g2d) {
        AffineTransform tx = new AffineTransform();
        tx.translate(400,400);
        tx.rotate(Math.toRadians(45.0f), image.getWidth()/2, image.getHeight()/2);
        tx.scale(0.75f, 0.75f);
        g2d.drawImage(image, tx, null);
    }
}

Op deze manier wordt een afbeelding maar 1x ingeladen (in de start methode), en steeds herbruikt met het tekenen. Let er wederom op dat de directory waar de afbeelding in staat gemarkeerd is als resource root, zoals in week 3 besproken is.

SpriteSheets

SpriteSheet In games worden veel spritesheets gebruikt om animaties of meerdere gelijksoortige afbeeldingen op te slaan. Spritesheets zijn afbeeldingen met meerdere kleine afbeeldingen erop. Een voordeel is dat je dan gemakkelijk door de afbeeldingen heen kunt lopen met een nummer door ze in een array te zetten. Daarnaast is het met veel graphics APIs sneller om stukjes van afbeeldingen te laten zien dan het tekenen van verschillende afbeeldingen.

Afbeeldingen kunnen gemakkelijk opgeknipt worden in code, doordat alle afbeeldingen dezelfde afmetingen hebben. Dit zou op de volgende manier kunnen:

private BufferedImage[] tiles;
public HelloImage() {
    try {
        BufferedImage image = ImageIO.read(getClass().getResource("/images/spritesheet.png"));
        tiles = new BufferedImage[24];
        //knip de afbeelding op in 24 stukjes van 32x32 pixels.
        for(int i = 0; i < 24; i++)
            tiles[i] = image.getSubImage(32 * (i%6), 32 * (i/6), 32, 32);
    } catch (Exception e) {
        e.printStackTrace();
    }

}

Sommige spritesheets hebben sprites die niet allemaal even groot zijn en niet op opvolgende plaasten staan. Hier zit dan altijd een extra bestand bij dat geparsed moet worden om de locatie van de sprites te vinden en uit te knippen.

Opgave 3-2. Bewegend karakter

Laad de bijgeleverde resource spritesheet afbeelding in, en hak deze op in stukjes. Laat hierna het karakter van links naar rechts over het venster lopen, met de loop-animatie. Laat zodra er met de muis geklikt wordt, de sprite stilstaan en een springanimatie afspelen. Let op dat je de spritesheet in delen moet zetten (met de getSubImage methode), en dat je dit opdelen maar 1 keer doet (dus bijvoorbeeld in de constructor)

moon


Blending

Standaard bij het tekenen van vormen en afbeeldingen zal java de nieuwe kleur over de oude kleur heenschrijven. Door de composite rules aan te passen kunnen we instellen hoe de nieuwe kleur over de oude kleur heenvalt. Het is ook mogelijk de twee kleuren te combineren. In de graphics wereld worden hierbij de termen Source en Destination gebruikt. Source is de kleur die je op dat moment aan het tekenen bent en destination is de kleur die al op ‘t scherm staat. De manieren van combineren staan in de AlphaComposite klasse. Via de AlphaComposite.getInstance() is het mogelijk om een nieuwe AlphaComposite aan te maken met een van de standaard-regels.

composite

Daarnaast is bij sommige composite rules ook een floating point alpha waarde op te geven, deze geeft aan hoeveel geblend moet worden. Door SRC_OVER te nemen met een alpha van 0.5, zal de alpha-waarde van de destinationafbeelding voor iedere pixel met 0.5 vermenigvuldigd worden. Op deze manier kun je afbeeldingen en objecten in laten faden, door deze waarde te animeren van 0 naar 1.

public void draw(FXGraphics2D g2d) {
    AffineTransform tx = new AffineTransform();

    g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.5f));
    g2d.fill(new Rectangle2D.Double(100,100,100,100));
}

Opgave 3-3. Image Fading

Maak een applicatie die 2 afbeeldingen inlaad, en rustig tussen deze 2 afbeeldingen fade. Maak hierbij gebruik van alpha blending om een rustige overgang tussen de 2 afbeeldingen te maken. Let op dat je bij een overgang eigenlijk maar 1 afbeelding tegelijk blend. De oude afbeelding blijft op 100% opacity staan, de nieuwe afbeelding gaat langzaam van 0% naar 100% opacity. Een extra uitdaging is om deze opgave met een lijst van afbeeldingen te maken, zodat verschillende afbeeldingen elkaar opvolgen.

moon


Clipping

Clipping Het is ook mogelijk om het tekenen op bepaalde gebieden van het scherm uit te zetten, zodat er niet getekent wordt. Dit noemen we clipping. De FXGraphics2D klasse heeft een setClip(Shape shape) methode, die een clipping-shape instelt. Als je null meegeeft als parameter, wordt de clipping uitgezet, en anders kan er alleen binnen de vorm getekend worden die je meegeeft. Dit kun je gebruiken om bijvoorbeeld een spotlight-effect te maken, of een vorm opvullen met andere shapes. Alles dat buiten deze vorm valt zal dus niet getekend worden.

public void draw(FXGraphics2D g2d) {
    Shape shape = new Ellipse2D.Double(stage.getWidth()/2-100, stage.getHeight()/2-100, 200, 200);
    g2d.draw(shape);
    g2d.clip(shape);

    Random r = new Random();
    for(int i = 0; i < 1000; i++) {
        g2d.setPaint(Color.getHSBColor(r.nextFloat(),1,1));
        g2d.drawLine(r.nextInt() % stage.getWidth(), r.nextInt() % stage.getHeight(), r.nextInt() % stage.getWidth(), r.nextInt() % stage.getHeight());
    }
}

Opgave 3-4. Spotlight

Maak een applicatie waarbij je met een soort ‘spotlight’ effect een achterliggende afbeelding kunt bekijken. Gebruik hiervoor de setClip functionaliteit in het Graphics object. Geef de spotlight een creatieve vorm. Laat hierbij de spotlight de muis volgen. Optioneel kun je door verschillende spotlights cyclen door met de muis te klikken.

moon


Timing

Niet elke computer kan meer dan 30 frames per seconden aan. Dit kan ook gebeuren als er een zware berekening op de achtergrond gedaan wordt. Om er voor te zorgen dat alle objecten dan toch even snel blijven bewegen kunnen we gebruik maken een deltatime parameter in de update methode.

Door gebruik te maken van een deltatime, die genormaliseerd is om basis van 1 seconden, voorkom je dit probleem. Als de computer waarop het programma afspeelt maar 5 FPS aan kan is de deltatime een stuk groter (deltatime=0.2) dan wanneer de computer 60 FPS (deltatime=0.017) aankan.

public class HelloAnimation extends Application {
    private double angle = 0;
    private Stage stage;

    @Override
    public void start(Stage stage) throws Exception {
        this.stage = stage;
        javafx.scene.canvas.Canvas canvas = new Canvas(1920, 1080);
        FXGraphics2D g2d = new FXGraphics2D(canvas.getGraphicsContext2D());
        draw(g2d);
        stage.setScene(new Scene(new Group(canvas)));
        stage.setTitle("Hello Animation");
        stage.show();

        new AnimationTimer() {
            long last = -1;
            @Override
            public void handle(long now) {
                if(last == -1)
                    last = now;
                update((now - last) / 1000000000.0);
                last = now;
                draw(g2d);
            }
        }.start();
    }

    public void draw(FXGraphics2D g2d) {
        AffineTransform tx = new AffineTransform();
        tx.translate(getWidth()/2, getHeight()/2);
        tx.rotate(angle);
        tx.translate(200, 0);

        g2d.fill(tx.createTransformedShape(new Rectangle2D.Double(-50,-50,100,100)));
    }

    @Override
    private void update(double deltatime) {
        long currentTime = System.currentTimeMillis();
        double deltaTime = (currentTime - lastTime) / 1000.0;
        lastTime = currentTime;
        angle+=deltaTime;
        repaint();
    }
}


Eindopdracht week 3

Opgave 3-5. Screensaver

Maak de oude windows lijnen-screensaver. Deze screensaver bestaat uit een aantal punten (4 in de afbeelding bij de opgave), die over het scherm stuiteren. Zodra ze een rand van het scherm aanraken gaan ze de andere richting op (dus als x < 0, xrichting = -xrichting, etc). Daarna worden er lijnen tussen deze punten getekend. Voor ieder punt wordt daarnaast een geschiedenis opgeslagen, die gebruikt worden om de oude lijnen ook te tekenen

moon

Einde van week 3