Explore Java and more with Jeff 'JavaJeff' Friesen

Painter's Canvas Mobile Edition

Last June, I introduced my Painter's Canvas article, which presents a technique for rendering complex graphics (such as fireworks, plasma, fractals, and fire). Instead of relying on nodes and javafx.scene.shape.Path objects (such as javafx.scene.shape.LineTo) to render the graphics, this technique relies on the concepts of canvas and painter for this task.

The crux of the article's code is the line iv.image = SwingUtils.toFXImage (painter.renderImage (width, height)) (excerpted from the Canvas class's paint() function). This line, which is repeatedly invoked in an animation loop, performs several tasks to ensure that the next animation frame is displayed:

  1. Invokes painter.renderImage (width, height) to render the next animation frame and returns its contents in a reusable java.awt.BufferedImage instance -- the same instance is used most of the time; a new instance is created whenever the width and/or height arguments change.
  2. Converts the BufferedImage instance to a javafx.scene.image.Image instance by invoking SwingUtils.toFXImage() with the BufferedImage instance as this method call's argument.
  3. Assigns the Image instance to iv's image property. Variable iv contains an instance of the javafx.scene.image.ImageView class. When a new Image instance is assigned to the image property, the scenegraph is forced to repaint the scene, which causes the image to be displayed.

Although new objects are still created during the animation loop, the idea is to minimize object creation. For example, imagine rendering the article's plasma scene using only nodes and Path objects such as LineTo. You might end up creating thousands more objects than are created using the article's canvas/painter technique. Much more memory would probably be needed, and performance could suffer.

Unfortunately, the technique presented in my Painter's Canvas article is restricted to the desktop and browser profiles. The article's CanvasDemo application won't run on the mobile profile mainly because of its dependence on the Abstract Window Toolkit and SwingUtils.toFXImage(). This article presents a mobile-friendly variant of that application.

Revisiting Essential Types

Before I reveal CanvasDemo running on the JavaFX SDK's mobile emulator, I'll briefly revisit the four types that make up this application. Specifically, I focus only on the necessary changes made to these types, starting with the single change made to Listing 1's Painter interface.

// Painter.java

import javafx.scene.image.Image;

public interface Painter
{
    public Image renderImage (int width, int height);
}

Listing 1: Painter.java

The Painter interface's renderImage() method no longer returns a BufferedImage instance because this latter class isn't supported by the mobile profile. I've modified this method's signature to indicate that it returns an instance of JavaFX's Image class.

Listing 2 presents the modified Canvas class.

/*
 * Canvas.fx
 */

import javafx.scene.CustomNode;
import javafx.scene.Group;
import javafx.scene.Node;

import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;

import javafx.scene.shape.Rectangle;

import javafx.scene.image.ImageView;

public class Canvas extends CustomNode
{
    public var width: Number;
    public var height: Number;

    public var fill: Paint = Color.BLACK;
    public var painter: Painter;

    public function paint (): Void
    {
        iv.image = painter.renderImage (width, height)
    }

    public override function create (): Node
    {
        Group
        {
            content:
            [
                Rectangle
                {
                    x: 0
                    y: 0
                    width: bind width
                    height: bind height
                    fill: bind fill
                }

                iv
            ]
        }
    }

    var iv: ImageView = ImageView
    {
        fitWidth: bind width
        fitHeight: bind height
    }
}

Listing 2: Canvas.fx

As with Listing 1, only a single change was made to the Canvas class. The previous iv.image = SwingUtils.toFXImage (painter.renderImage (width, height)) has been shortened to iv.image = painter.renderImage (width, height).

The largest number of changes are employed in the PlasmaPainter class, presented in Listing 3.

// PlasmaPainter.java

import static java.lang.Math.*;

import javax.microedition.lcdui.Graphics;
import javax.microedition.lcdui.Image;

import com.sun.fxme.image.ImageProgressListener;

import com.sun.fxme.runtime.scenegraph.SGImage;

public class PlasmaPainter implements Painter
{
    private int [] palette;

    private Image image;

    private boolean toggle;

    private int width, height;

    private byte [][] plasma;

    private int [] colors;

    private ImageProgressListener ipl;

    private javafx.scene.image.Image image1 = new javafx.scene.image.Image ();

    private javafx.scene.image.Image image2 = new javafx.scene.image.Image ();

    public PlasmaPainter ()
    {
        palette = new int [256];
        for (int i = 0; i < palette.length; i++)
            palette [i] = HSBtoRGB ((float) i/255.0f, 1.0f, 1.0f);

        ipl = new ImageProgressListener ()
        {
            @Override public void setError (boolean b)
            {
            }

            @Override public void setImageSize (int x, int y)
            {
            }

            @Override public void setProgress (int x)
            {
            }
        };
    }

    // I excerpted the following function from the java.awt.Color class because
    // MIDP (upon which JavaFX mobile is partly based) doesn't provide this
    // class nor this function.
    
    public static int HSBtoRGB (float hue, float saturation, float brightness)
    {
        int r = 0, g = 0, b = 0;

        if (saturation == 0)
            r = g = b = (int) (brightness*255.0f+0.5f);
        else
        {
            float h = (hue -(float) Math.floor (hue))*6.0f;
            float f = h-(float) java.lang.Math.floor (h);
            float p = brightness*(1.0f-saturation);
            float q = brightness*(1.0f-saturation*f);
            float t = brightness*(1.0f-(saturation*(1.0f-f)));
            switch ((int) h)
            {
               case 0: r = (int) (brightness*255.0f+0.5f);
                       g = (int) (t*255.0f+0.5f);
                       b = (int) (p*255.0f+0.5f);
                       break;

               case 1: r = (int) (q*255.0f+0.5f);
                       g = (int) (brightness*255.0f+0.5f);
                       b = (int) (p*255.0f+0.5f);
                       break;

               case 2: r = (int) (p*255.0f+0.5f);
                       g = (int) (brightness*255.0f+0.5f);
                       b = (int) (t*255.0f+0.5f);
                       break;

               case 3: r = (int) (p*255.0f+0.5f);
                       g = (int) (q*255.0f+0.5f);
                       b = (int) (brightness*255.0f+0.5f);
                       break;

               case 4: r = (int) (t*255.0f+0.5f);
                       g = (int) (p*255.0f+0.5f);
                       b = (int) (brightness*255.0f+0.5f);
                       break;

               case 5: r = (int) (brightness*255.0f+0.5f);
                       g = (int) (p*255.0f+0.5f);
                       b = (int) (q*255.0f+0.5f);
                       break;
            }
        }

        return 0xff000000|(r << 16)|(g << 8)|(b << 0);
    }

    @Override
    public javafx.scene.image.Image renderImage (int width, int height)
    {
        if (image == null || width != this.width || height != this.height)
        {
            image = Image.createImage (width, height);

            this.width = width;
            this.height = height;

            plasma = new byte [height][];
            for (int i = 0; i < plasma.length; i++)
                 plasma [i] = new byte [width];

            for (int row = 0; row < height; row++)
                for (int col = 0; col < width; col++)
                    plasma [row][col] =
                       (byte) ((128.0f+128.0f*cos (row/8f)+
                               128.0f+128.0f*cos (col/8f))/2);

            colors = new int [width*height];
        }

        int shift = (int) System.currentTimeMillis ()/5;

        Graphics g = image.getGraphics ();

        int base = 0;
        for (int row = 0; row < height; row++)
        {
            for (int col = 0; col < width; col++)
            {
                 colors [base+col] = palette [(plasma [row][col]+shift)&255];
                 g.setColor (colors [base+col]);
                 g.drawLine (col, row, col, row);
            }
            base += width;
        }

        SGImage sgi = new SGImage ();
        sgi.setProgressListener (ipl);
        sgi.loadingFinished (image);
        if (toggle)
        {
            toggle = !toggle;
            image1.set$image (sgi);
            return image1;
        }
        else
        {
            toggle = !toggle;
            image2.set$image (sgi);
            return image2;
        }
    }
}

Listing 3: PlasmaPainter.java

Because the mobile profile doesn't support the Abstract Window Toolkit, Listing 3 relies on the Java ME Mobile Information Device Profile's javax.microedition.lcdui.Graphics and javax.microedition.lcdui.Image classes to perform rendering.

Listing 3 also relies on an undocumented feature to store javax.microedition.lcdui.Image content in one of two javafx.scene.image.Image instances -- these precreated instances are alternately populated/returned so that the scenegraph repaints:

  1. An instance of the com.sun.fxme.runtime.scenegraph.SGImage class is instantiated. This class describes a scenegraph image node.
  2. SGImage's setProgressListener() method is invoked with a precreated instance of a class that implements the com.sun.fxme.image.ImageProgressListener interface. This is necessary to prevent a NullPointerException.
  3. SGImage's loadingFinished() method is invoked to store the javax.microedition.lcdui.Image content in the SGImage instance.
  4. Regardless of which javafx.scene.image.Image instance is being returned, this class's set$image() method is invoked to store the SGImage instance; the javafx.scene.image.Image instance is returned.

Finally, Listing 4 presents the modified Main class.

/*
 * Main.fx
 */

import javafx.animation.transition.PauseTransition;

import javafx.scene.Group;
import javafx.scene.Scene;

import javafx.scene.input.MouseEvent;

import javafx.scene.paint.Color;
import javafx.scene.paint.LinearGradient;
import javafx.scene.paint.Stop;

import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.scene.text.TextOrigin;

import javafx.stage.Stage;

def BACKGROUND_PAINT = LinearGradient
{
    startX: 0.0
    startY: 0.0
    endX: 0.0
    endY: 1.0
    stops:
    [
        Stop { offset: 0.0 color: Color.BLUE },
        Stop { offset: 1.0 color: Color.BLUEVIOLET }
    ]
}

Stage
{
    title: "Canvas Demo"

    var scene: Scene
    scene: scene = Scene
    {
        width: 300
        height: 300

        fill: BACKGROUND_PAINT

        var canvas: Canvas
        var group: Group
        var text: Text

        def animation = PauseTransition
        {
            duration: 30ms
            action: function (): Void
            {
                canvas.paint ();
            }
            repeatCount: PauseTransition.INDEFINITE
        }

        content: group = Group
        {
            content:
            [
                text = Text
                {
                    content: "PlasmaPainter"
                    fill: Color.YELLOW
                    font: Font { name: "Arial BOLD" size: 18 }
                    opacity: 0.75
                    
                    translateX: bind (scene.width-text.layoutBounds.width)/2
                    translateY: bind canvas.translateY-40
                    textOrigin: TextOrigin.TOP
                }

                canvas = Canvas
                {
                    width: bind scene.width/2
                    height: bind scene.height/2
                    translateX: bind (scene.width-canvas.width)/2
                    translateY: bind (scene.height-canvas.height)/2
                    painter: new PlasmaPainter ()
                }
            ]

            onMouseClicked: function (me: MouseEvent): Void
            {
                if (animation.running and not animation.paused)
                    animation.pause ()
                else
                    animation.play ()
            }
        }
    }
}

Listing 4: Main.fx

Main.fx is essentially the same as before, except that it no longer references the javafx.scene.effect.DropShadow and javafx.scene.effect.Reflection classes. These classes cannot be referenced because the mobile profile doesn't support effects.

Constructing and Running CanvasDemo

Perhaps you're wondering why I haven't included package statements in the aforementioned source files. These statements are missing because, unlike in my former article, I'm not using NetBeans to construct the mobile edition. Instead, I'm working with the JavaFX 1.2.3 SDK at the Windows XP command line, and don't want to bother with packages.

Assuming that you're using a similar setup, complete the following steps to build and run this application:

  1. Create a directory that contains the four aforementioned files. Ensure that this directory is current.
  2. Execute the following command line, which assumes that the JavaFX runtime is stored in C:\Program Files\JavaFX\javafx-sdk1.2.3: javac -cp "c:\Program Files\JavaFX\javafx-sdk1.2.3\lib\mobile\*.jar";"c:\Program Files\JavaFX\javafx-sdk1.2.3\emulator\runtimes\cldc-hi-javafx\lib\classes.zip";. PlasmaPainter.java
  3. Execute the following command line: javafxc Main.fx.
  4. Execute javafxpackager -p MOBILE -src . -appClass Main to create Main.jar and Main.jad files in a dist subdirectory.
  5. Make dist the current directory, and execute emulator -Xdescriptor:Main.jad. If all goes well, you should see CanvasDemo running on the emulator. See Figure 1.

CanvasDemo is now mobile-friendly.

Figure 1: CanvasDemo is now mobile-friendly.

I normally don't like to work with undocumented features, but had no choice to accomplish my task of getting CanvasDemo to run in the mobile profile. Unfortunately, this undocumented dependency means that this application might not run on the mobile emulator in a future version of JavaFX. If this should happen, I'll try to remedy the situation.

Conclusion

Now that you've seen desktop/browser and mobile versions of my CanvasDemo application, you're probably wishing that you didn't have to maintain two similar but different code bases to have a single application run on all of these profiles. Fortunately, I have a solution to this problem, and will present that solution in the forthcoming conclusion to my Painter's Canvas articles trilogy.


Download code.zip

Note: Application created with JavaFX 1.2.3 SDK on top of Java SE 6u16.


LEARN JAVA FROM APRESS