3-D Display Shelf With the PerspectiveTransform

By Josh Marinacci, October 20th, 2008

The PerspectiveTransform built into JavaFX can be used to easily create 3-D effects. This sample shows you how to build a 3-D display shelf using the PerspectiveTransform and a few calculations.

Understanding the Code

One of the most interesting effects in JavaFX is the PerspectiveTransform. Rather than changing the color of pixels the way that many effects do (like Blur and Glow), the PerspectiveTransform actually distorts and moves pixels onscreen. It essentially converts any four-sided polygon area to another four-sided polygon. You can combine this capability with a small bit of math to create cool 3-D effects.

The display shelf is composed of a single instance of the DisplayShelf class containing any number of Item objects. This Item class is the key to the 3-D effect. Item is composed of an Group that has ImageView and bounding rectangle node with the PerspectiveTransform effect applied to them, and overlaid with a rectangle to capture mouse events. The PerspectiveTransform is bound to a few calculation variables: lx, rx, uly, ury. These variables stand for left X (lx), right X (rx), upper left Y (uly) and upper right Y (ury). They represent the top two corners of the four-sided polygon. You need only the top corners because the lower corners are just a mirror of the top corners. Each variable is bound to a central angle by using a simple math equation that adjusts the position of the corner based on a sine or cosine of the current angle. Whenever the angle changes it updates these four variables, which then update the PerspectiveTransform, resulting in the actual pixel distortion onscreen. This code is shown in Figure 1.

Source Code
public class Item extends CustomNode {
    public var position:Integer = 0;
    public var angle = 45.0;
    public var shelf:DisplayShelf;
    public-init var image:Image;

    def width = 200;
    def height = 200;
    def radius = width/2;
    def back = height/10;

    var lx = bind radius - Math.sin(Math.toRadians(angle))*radius - 1;
    var rx = bind radius + Math.sin(Math.toRadians(angle))*radius + 1;
    var uly = bind 0 - Math.cos(Math.toRadians(angle))*back;
    var ury = bind 0 + Math.cos(Math.toRadians(angle))*back;

    function getPT(t:Number):PerspectiveTransform {
        return PerspectiveTransform {
            ulx: lx     uly: uly
            urx: rx     ury: ury
            lrx: rx     lry: height + uly
            llx: lx     lly: height + ury
        }
    }

    override public function create():Node {
        return Group {
            content: [
                Group {
                    content: [
                        ImageView {
                            image: image
                        },
                        Rectangle {
                            width: image.width
                            height: image.height
                            fill: Color.TRANSPARENT
                            stroke: Color.BLACK
                            smooth: true
                        }
                    ]
                    effect: bind PerspectiveTransform {
                        ulx: lx     uly: uly
                        urx: rx     ury: ury
                        lrx: rx     lry: height + uly
                        llx: lx     lly: height + ury
                    }

                },
                Rectangle {
                    translateX: bind lx
                    width: bind rx-lx
                    height: height
                    fill: Color.TRANSPARENT
                    blocksMouse: true
                    onMousePressed: function(e:MouseEvent) {
                        shelf.shiftToCenter(this);
                    };
                }
            ]
        }
    }

}

Figure 1: The Item.fx class

Notice the transparent Rectangle above the ImageView. The ImageView will be distorted onscreen by the PerspectiveTransform but its bounds will remain stable. This means that you can't use the ImageView bounds to calculate if a mouse click is inside of the image. The detection won't be accurate. The overlay Rectangle is added to provide a place to accurately capture the mouse clicks. Its width and translateX are also bound to the lx and rx variables so that the Rectangle will shift with the image. If you want to see these overlays onscreen change the opacity of the Rectangle.fill to something other than 0.

Combining Multiple Items Into a Display Shelf

The Item class just stores the image and applies the distortion. It does not handle the position of each item onscreen or any animation. That is what the DisplayShelf class does. I won't explain the entire code. The important parts are the shift and doLayout functions. The DisplayShelf uses three sequences of nodes internally: left, right, and center, to represent the three areas onscreen. The shift function in Figure 2 moves nodes from one sequence to another depending on the direction and magnitude of the shift.

Source Code
public class DisplayShelf extends CustomNode {

    ...
    
    public function shift(offset:Integer):Void {
        if(centerIndex <= 0 and offset > 0 ) {
            return;
        }
        if(centerIndex >= content.size()-1 and offset < 0) {
            return;
        }

        centerIndex -= offset;
        reparent(content[0..centerIndex-1], left);
        reparent([content[centerIndex]], center);
        reparent(Sequences.<<reverse>>(content[centerIndex+1..content.size()-1]) as Node[], right);
        doLayout();
    }

    /**
     * "Reparents" the node sequence newContent to its new parent Group
     * newParent, replacing any previous content,
     * after first removing them from their previous parent Group.
     */
    public function reparent(newContent:Node[], newParent:Group):Void {
        for (n in newContent) {
            if (n.parent instanceof Group) {
                delete n from (n.parent as Group).content;
            }
        }
        newParent.content = newContent;
    }

Figure 2: shift Function of the DisplayShelf Class

The last line of the shift function calls the doLayout function to actually animate the items from their old positions to their new positions. To perform this animation doLayout creates two sequences of KeyFrame objects, one sequence for the starting positions and one sequence for the ending positions. It saves the current positions into the startKeyframes sequence, then calculates new positions stored in the endKeyframes sequence. Finally it creates a Timeline by using those key frames to perform the final animation, as shown in Figure 3.

Source Code
    override function doLayout() {

        var startKeyframes:KeyFrame[];
        var endKeyframes:KeyFrame[];
        var duration = 0.5s;

        for(n in content) {
            def it = n as Item;
            insert KeyFrame { time: 0s values: [
                    it.translateX => it.translateX,
                    it.scaleX => it.scaleX,
                    it.scaleY => it.scaleY,
                    it.angle => it.angle
                    ] } into startKeyframes;
        }

        for(n in left.content) {
            def it = n as Item;
            var newX = -left.content.size()*spacing +  spacing * indexof n + leftOffset;
            insert KeyFrame { time: duration values: [
                    it.translateX => newX,
                    it.scaleX => scaleSmall,
                    it.scaleY => scaleSmall,
                    it.angle => 45
                ] } into endKeyframes;
        }

        for(n in center.content) {
            def it = n as Item;
            insert KeyFrame { time: duration values: [
                    it.translateX => 0,
                    it.scaleX => 1.0,
                    it.scaleY => 1.0,
                    it.angle => 90
                ] } into endKeyframes;
        }

        for(n in right.content) {
            def it = n as Item;
            var newX = right.content.size()*spacing -spacing * indexof n + rightOffset;
            insert KeyFrame { time: duration values: [
                    it.translateX => newX,
                    it.scaleX => scaleSmall,
                    it.scaleY => scaleSmall,
                    it.angle => 135
                ] } into endKeyframes;
        }

        var anim = Timeline {
            keyFrames: [startKeyframes, endKeyframes]
        };
        anim.play();
    }
    ...
}

Figure 3: Animating the Items Across the Stage